From fd1f53f2ee8832368485a04d958b0175ef8dfbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8A=80=E6=9C=AF=E8=80=81=E8=83=A1?= <1094551889@qq.com> Date: Mon, 11 May 2026 16:28:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E9=80=9A=E9=81=93=E5=92=8C=E6=94=B6=E6=AC=BE=E7=9B=91=E5=90=AC?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ChannelNotifyPayloadInterface 等支付插件通知契约,规范 pay_no 定位和插件返回校验。 新增微信、支付宝、收钱吧、Postar 个人收款插件适配,支持余额识别与备注识别。 新增 receipt-watcher 后端进程、Redis 队列 job 和平台事件监听,覆盖收款流水通知、商户通知、退款派发、转账派发与清算完成。 补齐个人收款监听相关系统配置、仓储、服务费冻结明细、订单后台操作和通道测试能力。 重构支付单创建、回调、费用、风控、结算和通道统计链路,统一状态流转与幂等处理。 --- app/command/EpayMapiTest.php | 24 +- app/command/EpayMockChainTest.php | 15 +- app/command/EpayV2ApiTest.php | 10 +- app/command/EpayV2Bootstrap.php | 3 +- app/command/MpayTest.php | 11 +- app/command/SystemConfigSync.php | 20 +- app/common/constant/AuthConstant.php | 34 +- app/common/constant/EpayProtocolConstant.php | 92 ++ app/common/constant/EventConstant.php | 5 + app/common/constant/FundFreezeConstant.php | 63 + app/common/constant/LedgerConstant.php | 11 +- app/common/constant/NotifyConstant.php | 10 + .../constant/PayOrderActionConstant.php | 88 ++ app/common/constant/PaymentQueueConstant.php | 42 + app/common/constant/RouteConstant.php | 5 +- app/common/constant/TradeConstant.php | 32 +- app/common/constant/TransferConstant.php | 29 +- .../interface/ChannelNotifyInterface.php | 24 + .../ChannelNotifyPayloadInterface.php | 32 + app/common/interface/PaymentInterface.php | 30 +- app/common/interface/QueueJobInterface.php | 31 + .../interface/TransferPluginInterface.php | 38 + app/common/payment/AlipayReceiptPayment.php | 780 +++++++++++ app/common/payment/EpayV1Payment.php | 902 ++++-------- app/common/payment/EpayV2Payment.php | 1243 +++++++---------- app/common/payment/PostarReceiptPayment.php | 1070 ++++++++++++++ .../payment/ShouQianBaReceiptPayment.php | 1055 ++++++++++++++ app/common/payment/WechatReceiptPayment.php | 780 +++++++++++ .../merchant/MerchantController.php | 32 +- .../payment/PaymentChannelController.php | 25 +- .../system/SystemConfigPageController.php | 2 +- .../controller/system/SystemController.php | 19 +- .../controller/trade/PayOrderController.php | 155 +- .../trade/SettlementOrderController.php | 54 +- .../MerchantApiCredentialValidator.php | 11 +- .../admin/validation/MerchantValidator.php | 14 +- .../validation/PayOrderActionValidator.php | 79 ++ .../validation/PaymentChannelValidator.php | 12 +- .../validation/SettlementOrderValidator.php | 4 +- .../controller/cashier/CashierController.php | 15 + .../api/controller/epay/EpayV2Controller.php | 12 + .../system/SystemPublicConfigController.php | 35 + app/http/api/validation/CashierValidator.php | 13 + app/http/api/validation/EpayV1Validator.php | 8 +- app/http/api/validation/EpayV2Validator.php | 21 +- .../controller/system/SystemController.php | 19 +- .../validation/MerchantPortalValidator.php | 4 +- app/listener/PaymentChannelStatListener.php | 130 ++ .../PaymentMerchantNotifyListener.php | 37 +- app/listener/PaymentSettlementListener.php | 67 + app/listener/ReceiptWatcherListener.php | 69 + app/model/merchant/MerchantAccountLedger.php | 3 - app/model/merchant/MerchantApiCredential.php | 4 +- app/model/merchant/MerchantFundFreeze.php | 64 + app/model/payment/PayOrder.php | 16 +- app/model/payment/RefundOrder.php | 3 +- app/process/PaymentRuntimeProcess.php | 20 +- app/process/ReceiptWatcherProcess.php | 173 +++ app/queue/job/MerchantNotifyJob.php | 48 + app/queue/job/README.md | 26 + app/queue/job/ReceiptFlowNotifyJob.php | 134 ++ app/queue/job/RefundDispatchJob.php | 49 + app/queue/job/SettlementCompleteJob.php | 48 + app/queue/job/TransferDispatchJob.php | 47 + app/queue/job/TransferQueryJob.php | 49 + app/queue/redis/MerchantNotify.php | 32 + app/queue/redis/README.md | 33 + app/queue/redis/ReceiptFlowNotify.php | 30 + app/queue/redis/RefundDispatch.php | 32 + app/queue/redis/SettlementComplete.php | 30 + app/queue/redis/TransferDispatch.php | 32 + app/queue/redis/TransferQuery.php | 32 + app/queue/support/AbstractQueueJob.php | 73 + app/queue/support/AbstractRedisConsumer.php | 82 ++ .../freeze/MerchantFundFreezeRepository.php | 120 ++ .../ops/stat/ChannelDailyStatRepository.php | 18 +- .../config/PaymentChannelRepository.php | 80 +- .../config/PaymentPluginConfRepository.php | 20 +- .../config/PaymentPollGroupBindRepository.php | 2 +- .../payment/config/PaymentTypeRepository.php | 20 +- .../payment/trade/PayOrderRepository.php | 152 +- .../payment/trade/RefundOrderRepository.php | 18 +- .../system/config/SystemConfigRepository.php | 34 +- app/route/admin.php | 338 +++-- app/route/api.php | 41 +- app/route/mer.php | 119 +- .../funds/MerchantAccountCommandService.php | 585 ++++++-- .../account/funds/MerchantAccountService.php | 98 +- .../ledger/MerchantAccountLedgerService.php | 3 - .../merchant/MerchantOverviewQueryService.php | 2 - .../merchant/auth/MerchantAuthService.php | 5 +- .../portal/MerchantPortalSupportService.php | 11 - .../MerchantApiCredentialQueryService.php | 5 - .../security/MerchantApiCredentialService.php | 7 +- .../ops/stat/ChannelDailyStatService.php | 189 ++- .../payment/cashier/CashierService.php | 212 ++- .../config/PaymentChannelCommandService.php | 41 +- .../config/PaymentChannelTestService.php | 267 ++++ .../config/PaymentPluginConfService.php | 39 +- .../payment/epay/EpaySignerManager.php | 26 +- .../epay/EpaySubmitPayloadAssembler.php | 102 ++ .../payment/epay/EpayV1ProtocolService.php | 456 ++---- .../payment/epay/EpayV2ProtocolService.php | 659 ++++----- .../order/PayOrderActionResolverService.php | 254 ++++ .../order/PayOrderAdminActionService.php | 371 +++++ .../payment/order/PayOrderAttemptService.php | 923 +++++++----- .../payment/order/PayOrderCallbackService.php | 420 +++--- .../order/PayOrderChannelDispatchService.php | 388 ++--- .../payment/order/PayOrderFeeService.php | 114 +- .../order/PayOrderLifecycleService.php | 226 ++- .../payment/order/PayOrderQueryService.php | 82 +- .../payment/order/PayOrderReportService.php | 16 +- .../order/PayOrderRiskControlService.php | 414 ++++++ app/service/payment/order/PayOrderService.php | 48 +- .../PaymentPluginNotifyResultValidator.php | 81 ++ .../order/PaymentPluginPayResultValidator.php | 76 + .../payment/order/RefundCreationService.php | 184 +-- .../payment/order/RefundDispatchService.php | 250 ++++ .../payment/order/RefundQueryService.php | 8 +- .../payment/order/RefundReportService.php | 3 +- app/service/payment/order/RefundService.php | 5 +- .../payment/receipt/ReceiptWatcherService.php | 555 ++++++++ .../MerchantNotifyDispatcherService.php | 265 +++- app/service/payment/runtime/NotifyService.php | 33 +- .../runtime/PaymentPluginFactoryService.php | 27 + .../payment/runtime/PaymentPluginManager.php | 13 +- .../payment/runtime/PaymentQueueService.php | 115 ++ .../runtime/PaymentRouteResolverService.php | 1 + .../PaymentRuntimeMaintenanceService.php | 232 +-- .../SettlementAutomationService.php | 168 +++ .../settlement/SettlementLifecycleService.php | 98 +- .../payment/transfer/TransferService.php | 601 +++++++- .../config/SystemConfigDefinitionService.php | 150 +- .../system/config/SystemConfigPageService.php | 60 +- .../config/SystemConfigRuntimeService.php | 47 +- .../config/SystemPublicConfigService.php | 135 ++ 136 files changed, 14416 insertions(+), 3992 deletions(-) create mode 100644 app/common/constant/EpayProtocolConstant.php create mode 100644 app/common/constant/FundFreezeConstant.php create mode 100644 app/common/constant/PayOrderActionConstant.php create mode 100644 app/common/constant/PaymentQueueConstant.php create mode 100644 app/common/interface/ChannelNotifyInterface.php create mode 100644 app/common/interface/ChannelNotifyPayloadInterface.php create mode 100644 app/common/interface/QueueJobInterface.php create mode 100644 app/common/interface/TransferPluginInterface.php create mode 100644 app/common/payment/AlipayReceiptPayment.php create mode 100644 app/common/payment/PostarReceiptPayment.php create mode 100644 app/common/payment/ShouQianBaReceiptPayment.php create mode 100644 app/common/payment/WechatReceiptPayment.php create mode 100644 app/http/admin/validation/PayOrderActionValidator.php create mode 100644 app/http/api/controller/system/SystemPublicConfigController.php create mode 100644 app/listener/PaymentChannelStatListener.php create mode 100644 app/listener/PaymentSettlementListener.php create mode 100644 app/listener/ReceiptWatcherListener.php create mode 100644 app/model/merchant/MerchantFundFreeze.php create mode 100644 app/process/ReceiptWatcherProcess.php create mode 100644 app/queue/job/MerchantNotifyJob.php create mode 100644 app/queue/job/README.md create mode 100644 app/queue/job/ReceiptFlowNotifyJob.php create mode 100644 app/queue/job/RefundDispatchJob.php create mode 100644 app/queue/job/SettlementCompleteJob.php create mode 100644 app/queue/job/TransferDispatchJob.php create mode 100644 app/queue/job/TransferQueryJob.php create mode 100644 app/queue/redis/MerchantNotify.php create mode 100644 app/queue/redis/README.md create mode 100644 app/queue/redis/ReceiptFlowNotify.php create mode 100644 app/queue/redis/RefundDispatch.php create mode 100644 app/queue/redis/SettlementComplete.php create mode 100644 app/queue/redis/TransferDispatch.php create mode 100644 app/queue/redis/TransferQuery.php create mode 100644 app/queue/support/AbstractQueueJob.php create mode 100644 app/queue/support/AbstractRedisConsumer.php create mode 100644 app/repository/account/freeze/MerchantFundFreezeRepository.php create mode 100644 app/service/payment/config/PaymentChannelTestService.php create mode 100644 app/service/payment/epay/EpaySubmitPayloadAssembler.php create mode 100644 app/service/payment/order/PayOrderActionResolverService.php create mode 100644 app/service/payment/order/PayOrderAdminActionService.php create mode 100644 app/service/payment/order/PayOrderRiskControlService.php create mode 100644 app/service/payment/order/PaymentPluginNotifyResultValidator.php create mode 100644 app/service/payment/order/PaymentPluginPayResultValidator.php create mode 100644 app/service/payment/order/RefundDispatchService.php create mode 100644 app/service/payment/receipt/ReceiptWatcherService.php create mode 100644 app/service/payment/runtime/PaymentQueueService.php create mode 100644 app/service/payment/settlement/SettlementAutomationService.php create mode 100644 app/service/system/config/SystemPublicConfigService.php diff --git a/app/command/EpayMapiTest.php b/app/command/EpayMapiTest.php index 4608481..9817fff 100644 --- a/app/command/EpayMapiTest.php +++ b/app/command/EpayMapiTest.php @@ -522,7 +522,10 @@ class EpayMapiTest extends Command $extJson = (array) ($payOrder['ext_json'] ?? []); $presentation = (array) ($extJson['presentation'] ?? []); - $summary = $this->summarizePayParamsSnapshot((array) ($presentation['params_snapshot'] ?? [])); + $summary = $this->summarizePayParams( + (array) ($presentation['pay_params'] ?? []), + (string) ($presentation['pay_page'] ?? '') + ); if ($summary !== []) { $output->writeln(' 插件返回:'); $output->writeln(' ' . $this->formatJson($summary)); @@ -533,15 +536,16 @@ class EpayMapiTest extends Command * 归纳支付参数快照。 * * @param array $snapshot 支付参数快照 + * @param string $payPage 承接页类型 * @return array 归纳结果 */ - private function summarizePayParamsSnapshot(array $snapshot): array + private function summarizePayParams(array $snapshot, string $payPage = ''): array { if ($snapshot === []) { return []; } - $summary = ['type' => (string) ($snapshot['type'] ?? '')]; + $summary = ['pay_page' => $payPage]; if (isset($snapshot['pay_product'])) { $summary['pay_product'] = (string) $snapshot['pay_product']; } @@ -549,20 +553,20 @@ class EpayMapiTest extends Command $summary['pay_action'] = (string) $snapshot['pay_action']; } - switch ((string) ($snapshot['type'] ?? '')) { - case 'form': + switch ($summary['pay_page']) { + case 'html': $html = $this->stringifyValue($snapshot['html'] ?? ''); $summary['html_length'] = strlen($html); $summary['html_head'] = $this->limitString($this->normalizeWhitespace($html), 160); break; case 'qrcode': - $summary['qrcode_url'] = $this->stringifyValue($snapshot['qrcode_url'] ?? $snapshot['qrcode_data'] ?? ''); + $summary['qrcode'] = $this->stringifyValue($snapshot['qrcode'] ?? ''); break; case 'urlscheme': - $summary['urlscheme'] = $this->stringifyValue($snapshot['urlscheme'] ?? $snapshot['order_str'] ?? ''); + $summary['urlscheme'] = $this->stringifyValue($snapshot['urlscheme'] ?? ''); break; - case 'url': - $summary['payurl'] = $this->stringifyValue($snapshot['payurl'] ?? ''); + case 'jump': + $summary['url'] = $this->stringifyValue($snapshot['url'] ?? ''); break; default: if (isset($snapshot['raw']) && is_array($snapshot['raw'])) { @@ -1075,7 +1079,7 @@ class EpayMapiTest extends Command private function resolve(string $class): object { try { - $instance = container_make($class, []); + $instance = container_get($class); } catch (\Throwable $e) { throw new CommandException("无法解析 {$class}。", 0, $e); } diff --git a/app/command/EpayMockChainTest.php b/app/command/EpayMockChainTest.php index 7223e47..e11ad7f 100644 --- a/app/command/EpayMockChainTest.php +++ b/app/command/EpayMockChainTest.php @@ -404,7 +404,6 @@ class EpayMockChainTest extends Command 'merchant_id' => (int) $merchant->id, 'api_key' => 'mock-v1-api-key-20260423', 'merchant_public_key' => '', - 'sign_type' => AuthConstant::API_SIGN_TYPE_MD5, 'status' => AuthConstant::CREDENTIAL_STATUS_ENABLED, ] ); @@ -477,7 +476,6 @@ class EpayMockChainTest extends Command 'merchant_id' => (int) $merchant->id, 'api_key' => 'mock-v2-api-key-20260423', 'merchant_public_key' => $merchantPair['public_key'], - 'sign_type' => AuthConstant::API_SIGN_TYPE_SHA256_WITH_RSA, 'status' => AuthConstant::CREDENTIAL_STATUS_ENABLED, ] ); @@ -847,10 +845,10 @@ class EpayMockChainTest extends Command /** @var EpaySignerManager $signerManager */ $signerManager = $this->resolve(EpaySignerManager::class); $payload['timestamp'] = (string) time(); - $payload['sign_type'] = AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA; + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_RSA; $signPayload = $payload; unset($signPayload['sign'], $signPayload['sign_type']); - $payload['sign'] = $signerManager->sign($signPayload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $privateKey); + $payload['sign'] = $signerManager->sign($signPayload, AuthConstant::API_SIGN_NAME_RSA, $privateKey); return $payload; } @@ -870,11 +868,10 @@ class EpayMockChainTest extends Command return ['passed' => false, 'message' => '响应缺少 sign']; } - $signType = $signerManager->normalizeSignType((string) ($responseData['sign_type'] ?? AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA)); $verifyPayload = $responseData; unset($verifyPayload['sign'], $verifyPayload['sign_type']); - if (!$signerManager->verify($verifyPayload, $signType, $sign, $platformPublicKey)) { + if (!$signerManager->verify($verifyPayload, (string) ($responseData['sign_type'] ?? AuthConstant::API_SIGN_NAME_RSA), $sign, $platformPublicKey)) { return ['passed' => false, 'message' => '响应验签失败']; } @@ -942,11 +939,11 @@ class EpayMockChainTest extends Command 'param' => (string) (((array) ($bizOrder->ext_json['merchant'] ?? []))['param'] ?? ''), 'timestamp' => (string) time(), 'endtime' => FormatHelper::dateTime($this->nowDateTime()), - 'sign_type' => AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, + 'sign_type' => AuthConstant::API_SIGN_NAME_RSA, ]; $signPayload = $payload; unset($signPayload['sign'], $signPayload['sign_type']); - $payload['sign'] = $signerManager->sign($signPayload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $mockPlatformPrivateKey); + $payload['sign'] = $signerManager->sign($signPayload, AuthConstant::API_SIGN_NAME_RSA, $mockPlatformPrivateKey); return $payload; } @@ -1300,7 +1297,7 @@ class EpayMockChainTest extends Command private function resolve(string $class): object { try { - $instance = container_make($class, []); + $instance = container_get($class); } catch (Throwable $e) { throw new CommandException('无法解析 ' . $class, 50002, $e); } diff --git a/app/command/EpayV2ApiTest.php b/app/command/EpayV2ApiTest.php index 62a774a..e6421e0 100644 --- a/app/command/EpayV2ApiTest.php +++ b/app/command/EpayV2ApiTest.php @@ -506,8 +506,8 @@ class EpayV2ApiTest extends Command $signerManager = $this->resolve(EpaySignerManager::class); $payload['pid'] = (int) ($payload['pid'] ?? 0); $payload['timestamp'] = (string) time(); - $payload['sign_type'] = AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA; - $payload['sign'] = $signerManager->sign($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $privateKey); + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_RSA; + $payload['sign'] = $signerManager->sign($payload, AuthConstant::API_SIGN_NAME_RSA, $privateKey); return $payload; } @@ -762,8 +762,8 @@ class EpayV2ApiTest extends Command 'pid' => 1, 'timestamp' => (string) time(), ]; - $sign = $signerManager->sign($probePayload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $merchantPrivateKey); - if (!$signerManager->verify($probePayload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $sign, $merchantPublicKey)) { + $sign = $signerManager->sign($probePayload, AuthConstant::API_SIGN_NAME_RSA, $merchantPrivateKey); + if (!$signerManager->verify($probePayload, AuthConstant::API_SIGN_NAME_RSA, $sign, $merchantPublicKey)) { throw new CommandException('提供的商户私钥与后台配置的商户公钥不匹配。'); } } @@ -1203,7 +1203,7 @@ class EpayV2ApiTest extends Command private function resolve(string $class): object { try { - $instance = container_make($class, []); + $instance = container_get($class); } catch (\Throwable $e) { throw new CommandException("无法解析 {$class}。", 0, $e); } diff --git a/app/command/EpayV2Bootstrap.php b/app/command/EpayV2Bootstrap.php index 8953a3f..6e41857 100644 --- a/app/command/EpayV2Bootstrap.php +++ b/app/command/EpayV2Bootstrap.php @@ -143,7 +143,6 @@ class EpayV2Bootstrap extends Command [ 'merchant_id' => (int) $merchant->id, 'status' => AuthConstant::CREDENTIAL_STATUS_ENABLED, - 'sign_type' => (int) ($credential?->sign_type ?? AuthConstant::API_SIGN_TYPE_MD5), 'api_key' => (string) ($credential?->api_key ?: bin2hex(random_bytes(16))), 'merchant_public_key' => $pair['public_key'], ] @@ -476,7 +475,7 @@ POWERSHELL; private function resolve(string $class): object { try { - $instance = container_make($class, []); + $instance = container_get($class); } catch (\Throwable $e) { throw new CommandException("无法解析 {$class}。", 0, $e); } diff --git a/app/command/MpayTest.php b/app/command/MpayTest.php index 30fdead..237c318 100644 --- a/app/command/MpayTest.php +++ b/app/command/MpayTest.php @@ -144,6 +144,10 @@ class MpayTest extends Command 'pay_amount' => $payAmount, 'subject' => $this->envString('MPAY_TEST_PAYMENT_SUBJECT', 'mpay smoke payment'), 'body' => $this->envString('MPAY_TEST_PAYMENT_BODY', 'mpay smoke payment'), + 'notify_url' => $this->envString('MPAY_TEST_PAYMENT_NOTIFY_URL', ''), + 'return_url' => $this->envString('MPAY_TEST_PAYMENT_RETURN_URL', ''), + 'client_ip' => $this->envString('MPAY_TEST_PAYMENT_CLIENT_IP', '127.0.0.1'), + 'device' => $this->envString('MPAY_TEST_PAYMENT_DEVICE', 'pc'), 'ext_json' => $this->envJson('MPAY_TEST_PAYMENT_EXT_JSON', []), ]); @@ -164,7 +168,6 @@ class MpayTest extends Command $message .= ', 已标记超时'; } elseif ($this->envBool('MPAY_TEST_PAYMENT_MARK_SUCCESS', false)) { $service->markPaySuccess((string) $payOrder->pay_no, [ - 'fee_actual_amount' => $this->envInt('MPAY_TEST_PAYMENT_FEE_AMOUNT', (int) $payOrder->fee_estimated_amount), 'channel_trade_no' => $this->envString('MPAY_TEST_PAYMENT_CHANNEL_TRADE_NO', $this->generateTestNo('CH-')), 'channel_order_no' => $this->envString('MPAY_TEST_PAYMENT_CHANNEL_ORDER_NO', $this->generateTestNo('CO-')), ]); @@ -273,10 +276,10 @@ class MpayTest extends Command 'pay_no' => (string) $payOrder->pay_no, 'refund_no' => '', 'pay_amount' => (int) $payOrder->pay_amount, - 'fee_amount' => (int) $payOrder->fee_actual_amount, + 'fee_amount' => (int) $payOrder->service_fee_amount, 'refund_amount' => 0, 'fee_reverse_amount' => 0, - 'net_amount' => max(0, (int) $payOrder->pay_amount - (int) $payOrder->fee_actual_amount), + 'net_amount' => max(0, (int) $payOrder->pay_amount - (int) $payOrder->service_fee_amount), 'item_status' => TradeConstant::SETTLEMENT_STATUS_PENDING, ]]; $merchantId = (int) $payOrder->merchant_id; @@ -442,7 +445,7 @@ class MpayTest extends Command private function resolve(string $class): object { try { - $instance = container_make($class, []); + $instance = container_get($class); } catch (\Throwable $e) { throw new CommandException("无法解析 {$class}。", 0, $e); } diff --git a/app/command/SystemConfigSync.php b/app/command/SystemConfigSync.php index c0dca7b..9f4d7a7 100644 --- a/app/command/SystemConfigSync.php +++ b/app/command/SystemConfigSync.php @@ -39,32 +39,23 @@ class SystemConfigSync extends Command { try { /** @var SystemConfigDefinitionService $definitionService */ - $definitionService = container_make(SystemConfigDefinitionService::class, []); + $definitionService = container_get(SystemConfigDefinitionService::class); /** @var SystemConfigRepository $repository */ - $repository = container_make(SystemConfigRepository::class, []); + $repository = container_get(SystemConfigRepository::class); /** @var SystemConfigRuntimeService $runtimeService */ - $runtimeService = container_make(SystemConfigRuntimeService::class, []); + $runtimeService = container_get(SystemConfigRuntimeService::class); $tabs = $definitionService->tabs(); $written = 0; foreach ($tabs as $tab) { $groupCode = (string) ($tab['key'] ?? ''); - foreach ((array) ($tab['rules'] ?? []) as $rule) { - if (!is_array($rule)) { - continue; - } - - $configKey = strtolower(trim((string) ($rule['field'] ?? ''))); - if ($configKey === '' || str_starts_with($configKey, '__')) { - continue; - } - + foreach ($definitionService->defaultStorageValues($tab) as $configKey => $configValue) { $repository->updateOrCreate( ['config_key' => $configKey], [ 'group_code' => $groupCode, - 'config_value' => (string) ($rule['value'] ?? ''), + 'config_value' => $configValue, ] ); @@ -84,4 +75,3 @@ class SystemConfigSync extends Command } } } - diff --git a/app/common/constant/AuthConstant.php b/app/common/constant/AuthConstant.php index 3b2318c..16ce41d 100644 --- a/app/common/constant/AuthConstant.php +++ b/app/common/constant/AuthConstant.php @@ -44,43 +44,15 @@ final class AuthConstant */ public const CREDENTIAL_STATUS_ENABLED = 1; - /** - * API 签名类型:MD5。 - */ - public const API_SIGN_TYPE_MD5 = 0; - - /** - * API 签名类型:SHA256WithRSA。 - */ - public const API_SIGN_TYPE_SHA256_WITH_RSA = 1; - /** * API 签名类型名称:MD5。 */ public const API_SIGN_NAME_MD5 = 'MD5'; /** - * API 签名类型名称:SHA256WithRSA。 + * API 签名类型名称:RSA。 */ - public const API_SIGN_NAME_SHA256_WITH_RSA = 'SHA256WithRSA'; - - /** - * API 签名类型归一化名称:SHA256WITHRSA。 - */ - public const API_SIGN_NORMALIZED_SHA256_WITH_RSA = 'SHA256WITHRSA'; - - /** - * 获取签名类型映射。 - * - * @return array 签名类型名称表 - */ - public static function signTypeMap(): array - { - return [ - self::API_SIGN_TYPE_MD5 => self::API_SIGN_NAME_MD5, - self::API_SIGN_TYPE_SHA256_WITH_RSA => self::API_SIGN_NAME_SHA256_WITH_RSA, - ]; - } + public const API_SIGN_NAME_RSA = 'RSA'; /** * 获取接口凭证状态映射。 @@ -108,5 +80,3 @@ final class AuthConstant ]; } } - - diff --git a/app/common/constant/EpayProtocolConstant.php b/app/common/constant/EpayProtocolConstant.php new file mode 100644 index 0000000..69e7b84 --- /dev/null +++ b/app/common/constant/EpayProtocolConstant.php @@ -0,0 +1,92 @@ + + */ + public static function v1Devices(): array + { + return [ + self::DEVICE_PC, + self::DEVICE_MOBILE, + self::DEVICE_QQ, + self::DEVICE_WECHAT, + self::DEVICE_ALIPAY, + self::DEVICE_JUMP, + ]; + } + + /** + * V2 支持的设备类型。 + * + * @return array + */ + public static function v2Devices(): array + { + return [ + self::DEVICE_PC, + self::DEVICE_MOBILE, + self::DEVICE_QQ, + self::DEVICE_WECHAT, + self::DEVICE_ALIPAY, + ]; + } +} diff --git a/app/common/constant/EventConstant.php b/app/common/constant/EventConstant.php index 3f67290..d4ded85 100644 --- a/app/common/constant/EventConstant.php +++ b/app/common/constant/EventConstant.php @@ -35,6 +35,11 @@ final class EventConstant */ public const PAYMENT_PAY_ORDER_TIMEOUT = 'payment.pay_order.timeout'; + /** + * 网页流水监听相关配置已变更。 + */ + public const PAYMENT_RECEIPT_WATCHER_CONFIG_CHANGED = 'payment.receipt_watcher.config.changed'; + /** * 退款单进入成功态。 */ diff --git a/app/common/constant/FundFreezeConstant.php b/app/common/constant/FundFreezeConstant.php new file mode 100644 index 0000000..9c7b500 --- /dev/null +++ b/app/common/constant/FundFreezeConstant.php @@ -0,0 +1,63 @@ + 冻结类型文案 + */ + public static function typeMap(): array + { + return [ + self::TYPE_PAY_ORDER => '支付订单', + self::TYPE_MANUAL_AMOUNT => '人工指定金额', + self::TYPE_PAY_FEE => '支付平台服务费', + ]; + } + + /** + * 获取冻结状态文案。 + * + * @return array 冻结状态文案 + */ + public static function statusMap(): array + { + return [ + self::STATUS_ACTIVE => '冻结中', + self::STATUS_RELEASED => '已解冻', + ]; + } +} diff --git a/app/common/constant/LedgerConstant.php b/app/common/constant/LedgerConstant.php index 7735e66..4964e0f 100644 --- a/app/common/constant/LedgerConstant.php +++ b/app/common/constant/LedgerConstant.php @@ -43,6 +43,14 @@ final class LedgerConstant * 转账释放流水。 */ public const BIZ_TYPE_TRANSFER_RELEASE = 8; + /** + * 风控资金冻结流水。 + */ + public const BIZ_TYPE_RISK_FREEZE = 9; + /** + * 风控资金释放流水。 + */ + public const BIZ_TYPE_RISK_RELEASE = 10; /** * 账务事件的创建动作。 @@ -87,6 +95,8 @@ final class LedgerConstant self::BIZ_TYPE_TRANSFER_DEDUCT => '转账扣款', self::BIZ_TYPE_TRANSFER_FEE => '转账手续费', self::BIZ_TYPE_TRANSFER_RELEASE => '转账释放', + self::BIZ_TYPE_RISK_FREEZE => '风控冻结', + self::BIZ_TYPE_RISK_RELEASE => '风控释放', ]; } @@ -119,4 +129,3 @@ final class LedgerConstant } } - diff --git a/app/common/constant/NotifyConstant.php b/app/common/constant/NotifyConstant.php index fb797a3..cabb6aa 100644 --- a/app/common/constant/NotifyConstant.php +++ b/app/common/constant/NotifyConstant.php @@ -20,6 +20,16 @@ final class NotifyConstant */ public const EVENT_SETTLEMENT_SUCCESS = 'SETTLEMENT_SUCCESS'; + /** + * 商户通知成功响应。 + */ + public const MERCHANT_SUCCESS_RESPONSE = 'success'; + + /** + * ePay 通知交易成功状态。 + */ + public const EPAY_TRADE_STATUS_SUCCESS = 'TRADE_SUCCESS'; + /** * 异步通知类型。 */ diff --git a/app/common/constant/PayOrderActionConstant.php b/app/common/constant/PayOrderActionConstant.php new file mode 100644 index 0000000..d832e22 --- /dev/null +++ b/app/common/constant/PayOrderActionConstant.php @@ -0,0 +1,88 @@ + 操作文案映射 + */ + public static function actionLabelMap(): array + { + return [ + self::MANUAL_SUCCESS => '手动补单', + self::RENOTIFY => '重新通知', + self::ACTIVE_QUERY => '主动查询', + self::API_REFUND => 'API退款', + self::MANUAL_REFUND => '手动退款', + self::FREEZE => '冻结订单', + self::UNFREEZE => '解冻订单', + ]; + } + + /** + * 获取冻结状态文案。 + * + * @return array 冻结状态文案映射 + */ + public static function freezeStatusMap(): array + { + return [ + self::FREEZE_STATUS_NORMAL => '正常', + self::FREEZE_STATUS_FROZEN => '已冻结', + ]; + } +} diff --git a/app/common/constant/PaymentQueueConstant.php b/app/common/constant/PaymentQueueConstant.php new file mode 100644 index 0000000..0d776e4 --- /dev/null +++ b/app/common/constant/PaymentQueueConstant.php @@ -0,0 +1,42 @@ + '平台代收', - self::CHANNEL_TYPE_MERCHANT_SELF => '商户自有', + self::CHANNEL_TYPE_MERCHANT_SELF => '商户自收', ]; } @@ -86,4 +86,3 @@ final class RouteConstant } - diff --git a/app/common/constant/TradeConstant.php b/app/common/constant/TradeConstant.php index 7b4f8e3..c5a447e 100644 --- a/app/common/constant/TradeConstant.php +++ b/app/common/constant/TradeConstant.php @@ -54,21 +54,21 @@ final class TradeConstant public const ORDER_STATUS_TIMEOUT = 5; /** - * 手续费未处理。 + * 平台服务费未处理。 */ - public const FEE_STATUS_NONE = 0; + public const SERVICE_FEE_STATUS_NONE = 0; /** - * 手续费已冻结。 + * 平台服务费已冻结。 */ - public const FEE_STATUS_FROZEN = 1; + public const SERVICE_FEE_STATUS_FROZEN = 1; /** - * 手续费已扣除。 + * 平台服务费已扣除。 */ - public const FEE_STATUS_DEDUCTED = 2; + public const SERVICE_FEE_STATUS_DEDUCTED = 2; /** - * 手续费已释放。 + * 平台服务费已释放。 */ - public const FEE_STATUS_RELEASED = 3; + public const SERVICE_FEE_STATUS_RELEASED = 3; /** * 清算状态为空。 @@ -142,17 +142,17 @@ final class TradeConstant } /** - * 获取手续费状态映射。 + * 获取平台服务费状态映射。 * - * @return array 手续费状态名称表 + * @return array 平台服务费状态名称表 */ - public static function feeStatusMap(): array + public static function serviceFeeStatusMap(): array { return [ - self::FEE_STATUS_NONE => '无', - self::FEE_STATUS_FROZEN => '冻结', - self::FEE_STATUS_DEDUCTED => '已扣', - self::FEE_STATUS_RELEASED => '已释放', + self::SERVICE_FEE_STATUS_NONE => '无', + self::SERVICE_FEE_STATUS_FROZEN => '冻结', + self::SERVICE_FEE_STATUS_DEDUCTED => '已扣', + self::SERVICE_FEE_STATUS_RELEASED => '已释放', ]; } @@ -301,5 +301,3 @@ final class TradeConstant } } - - diff --git a/app/common/constant/TransferConstant.php b/app/common/constant/TransferConstant.php index a70f812..7aaf240 100644 --- a/app/common/constant/TransferConstant.php +++ b/app/common/constant/TransferConstant.php @@ -11,14 +11,22 @@ final class TransferConstant * 转账待处理状态。 */ public const TRANSFER_STATUS_PENDING = 0; + /** + * 转账处理中状态。 + */ + public const TRANSFER_STATUS_PROCESSING = 1; /** * 转账成功状态。 */ - public const TRANSFER_STATUS_SUCCESS = 1; + public const TRANSFER_STATUS_SUCCESS = 2; /** * 转账失败状态。 */ - public const TRANSFER_STATUS_FAILED = 2; + public const TRANSFER_STATUS_FAILED = 3; + /** + * 转账关闭状态。 + */ + public const TRANSFER_STATUS_CLOSED = 4; /** * 获取转账状态映射。 @@ -29,8 +37,25 @@ final class TransferConstant { return [ self::TRANSFER_STATUS_PENDING => '待处理', + self::TRANSFER_STATUS_PROCESSING => '处理中', self::TRANSFER_STATUS_SUCCESS => '成功', self::TRANSFER_STATUS_FAILED => '失败', + self::TRANSFER_STATUS_CLOSED => '关闭', ]; } + + /** + * 判断是否为转账终态。 + * + * @param int $status 转账状态 + * @return bool 是否终态 + */ + public static function isTerminalStatus(int $status): bool + { + return in_array($status, [ + self::TRANSFER_STATUS_SUCCESS, + self::TRANSFER_STATUS_FAILED, + self::TRANSFER_STATUS_CLOSED, + ], true); + } } diff --git a/app/common/interface/ChannelNotifyInterface.php b/app/common/interface/ChannelNotifyInterface.php new file mode 100644 index 0000000..c154f5c --- /dev/null +++ b/app/common/interface/ChannelNotifyInterface.php @@ -0,0 +1,24 @@ + $payload 已归一化的通道通知载荷 + * @return array{pay_no:string} 定位结果 + */ + public function channelNotifyPayload(array $payload): array; + + /** + * 解析数组载荷并返回标准插件通知结果。 + * + * @param array $payload 已归一化的通道通知载荷 + * @return array 插件回调结果 + */ + public function notifyPayload(array $payload): array; +} diff --git a/app/common/interface/PaymentInterface.php b/app/common/interface/PaymentInterface.php index 0b97402..a77c56e 100644 --- a/app/common/interface/PaymentInterface.php +++ b/app/common/interface/PaymentInterface.php @@ -24,18 +24,25 @@ interface PaymentInterface * 发起支付下单。 * * 插件必须返回系统标准结构,服务层会严格校验后写入支付单 `ext_json.presentation`: - * - `pay_product`:支付产品或上游支付方式,例如 `alipay`、`wxpay`、`alipay_h5` - * - `pay_action`:支付动作,例如 `jump`、`qrcode`、`html`、`jsapi` - * - `pay_params.type`:收银台承接类型,支持 `jump`、`web`、`h5`、`qrcode`、`html`、`jsapi`、`urlscheme`、`mini`、`pos`、`transfer`、`json`、`error` - * - `chan_order_no`:渠道订单号,必须返回 + * - `pay_page`:收银台承接页类型,支持 `qrcode`、`html`、`jump`、`jsapi`、`urlscheme`、`error`、`ok`、`page` + * - `pay_type`:支付方式,例如 `alipay`、`wxpay`、`unionpay` + * - `pay_product`:插件调用的支付产品,例如 `scan`、`h5`、`jsapi`;没有更细产品时可与 `pay_type` 相同 + * - `pay_action`:插件调用动作或方法,例如 `qrcode`、`jump`、`html`、`jsapi` + * - `pay_params`:插件调用支付产品返回的支付参数,平台不向其中补写展示字段 + * - `chan_order_no`:渠道订单号;`error`、`html`、`ok` 场景允许为空 * - `chan_trade_no`:渠道交易号,可选;未生成时返回空字符串 - * - `ext_json`:插件私有轻量信息,可选;原始响应不要塞入支付单扩展 * - * `pay_params` 必须带上对应 `type` 的必要载荷: - * - 跳转类:`redirect_url` / `payurl` / `mweb_url` - * - 二维码类:`qrcode_text` / `qrcode_data` / `qrcode_url` - * - 表单类:`html` 或 `action` - * - JSAPI / URL Scheme / 小程序:对应拉起参数或跳转参数 + * `pay_params` 按对应 `pay_page` 放入承接页需要的固定字段: + * - `qrcode`:`qrcode`,二维码文字内容。 + * - `html`:`html`,插件返回的 HTML 代码。 + * - `jump`:`url`,需要跳转的支付地址。 + * - `jsapi`:微信、支付宝官方 JSAPI 拉起参数。 + * - `urlscheme`:`urlscheme`,需要打开的应用或小程序地址。 + * - `error`:`error_msg`,承接页展示的错误信息。 + * - `ok`:无需固定支付参数,承接页会优先使用订单 `return_url` 返回商户。 + * - `page`:`_page`,已在收银台白名单注册的组件名,其他字段由该组件消费。 + * + * 插件可把上游原始响应放入 `pay_params.raw` 方便排障,这是推荐约定,不由平台强制校验。 * * @param array $order 订单参数 * @return array 下单结果 @@ -52,7 +59,6 @@ interface PaymentInterface * - `channel_status`:渠道原始状态,可选 * - `message`:查询说明,可选 * - `paid_at` / `failed_at`:终态时间,可选 - * - `ext_json`:插件私有轻量补充信息,可选 * * @param array $order 订单参数 * @return array 查询结果 @@ -87,8 +93,6 @@ interface PaymentInterface * - `message`:回调处理说明,可选 * - `channel_error_code` / `channel_error_msg`:渠道失败原因,可选 * - `paid_at` / `failed_at`:支付成功或失败时间,可选 - * - `fee_actual_amount`:实际手续费,单位分,可选 - * - `ext_json`:插件私有的轻量补充信息,可选;原始回调和解析结果会进入回调日志,不要塞进支付单扩展 * * 插件在验签失败、报文非法或关键字段缺失时,应直接抛出 `PaymentException`。 * 只有在回调可信时,才返回标准结果数组。 diff --git a/app/common/interface/QueueJobInterface.php b/app/common/interface/QueueJobInterface.php new file mode 100644 index 0000000..f7fd5a5 --- /dev/null +++ b/app/common/interface/QueueJobInterface.php @@ -0,0 +1,31 @@ + $data 队列消息 + * @return void + */ + public function handle(array $data): void; + + /** + * 处理消费失败。 + * + * @param Throwable $exception 异常 + * @param array $package 原始队列包 + * @return void + */ + public function failed(Throwable $exception, array $package): void; +} diff --git a/app/common/interface/TransferPluginInterface.php b/app/common/interface/TransferPluginInterface.php new file mode 100644 index 0000000..bc2d8e9 --- /dev/null +++ b/app/common/interface/TransferPluginInterface.php @@ -0,0 +1,38 @@ + $order 转账订单参数 + * @return array 转账结果 + */ + public function transfer(array $order): array; + + /** + * 查询转账状态。 + * + * @param array $order 转账订单参数 + * @return array 查询结果 + */ + public function transferQuery(array $order): array; + + /** + * 查询转账余额。 + * + * @param array $order 查询参数 + * @return array 余额结果 + */ + public function transferBalance(array $order): array; +} diff --git a/app/common/payment/AlipayReceiptPayment.php b/app/common/payment/AlipayReceiptPayment.php new file mode 100644 index 0000000..ed5e4e8 --- /dev/null +++ b/app/common/payment/AlipayReceiptPayment.php @@ -0,0 +1,780 @@ + + */ + protected array $paymentInfo = [ + 'code' => 'alipay_receipt', + 'name' => '支付宝个人收款监听', + 'author' => 'MPAY', + 'version' => '1.0.0', + 'pay_types' => ['alipay'], + 'transfer_types' => [], + 'config_schema' => [ + [ + 'type' => 'radio', + 'field' => 'receipt_match_mode', + 'title' => '订单匹配模式', + 'value' => 'amount', + 'options' => [ + ['label' => '金额变动', 'value' => 'amount'], + ['label' => '付款备注', 'value' => 'remark'], + ], + 'validate' => [ + ['required' => true, 'message' => '订单匹配模式不能为空'], + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'receipt_valid_seconds', + 'title' => '识别有效期(秒)', + 'value' => 300, + 'props' => [ + 'min' => 60, + 'max' => 1800, + 'step' => 60, + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'amount_offset_max', + 'title' => '最大金额偏移(分)', + 'value' => 99, + 'props' => [ + 'min' => 0, + 'max' => 99, + 'step' => 1, + ], + ], + [ + 'type' => 'input', + 'field' => 'sms_forwarder_secret', + 'title' => 'SmsForwarder密钥', + 'value' => '', + 'props' => [ + 'placeholder' => '用于校验 SmsForwarder sign', + 'type' => 'password', + ], + 'validate' => [ + ['required' => true, 'message' => 'SmsForwarder密钥不能为空'], + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'sms_forwarder_time_tolerance', + 'title' => '签名时间容差(秒)', + 'value' => 300, + 'props' => [ + 'min' => 30, + 'max' => 1800, + 'step' => 30, + ], + ], + [ + 'type' => 'textarea', + 'field' => 'receipt_qrcode_content', + 'title' => '支付宝收款码内容', + 'value' => '', + 'props' => [ + 'placeholder' => '可填写支付宝收款码解码后的内容,优先用于二维码承接页', + 'rows' => 4, + ], + ], + [ + 'type' => 'input', + 'field' => 'receipt_qrcode_image', + 'title' => '支付宝收款码图片', + 'value' => '', + 'props' => [ + 'placeholder' => '收款码图片 URL,未配置收款码内容时使用', + ], + ], + ], + ]; + + /** + * 发起个人收款。 + * + * 不调用支付宝官方接口,只准备收银台二维码承接参数。 + * 金额模式会分配一个有效期内唯一金额;备注模式会分配一个 4 位备注码缓存。 + * + * @param array $order 标准插件下单参数 + * @return array + */ + public function pay(array $order): array + { + $payNo = (string) $order['pay_no']; + $mode = $this->receiptMatchMode(); + $prepared = $mode === 'remark' + ? $this->prepareRemarkReceipt($payNo) + : $this->prepareAmountReceipt($payNo); + + $qrcode = trim((string) $this->getConfig('receipt_qrcode_content', '')); + $image = trim((string) $this->getConfig('receipt_qrcode_image', '')); + if ($qrcode === '' && $image === '') { + throw new PaymentException('支付宝个人收款插件未配置收款码', 40200); + } + + $params = [ + '_page' => 'receiptQrcode', + 'amount' => FormatHelper::amount((int) $prepared['pay_amount']), + 'original_amount' => FormatHelper::amount((int) $prepared['original_amount']), + 'receipt_match_mode' => $mode, + 'receipt_valid_seconds' => $this->receiptValidSeconds(), + 'expire_at' => (string) $prepared['expire_at'], + 'expire_at_timestamp' => (int) strtotime((string) $prepared['expire_at']), + 'description' => $mode === 'remark' + ? '请使用支付宝扫码,并在付款备注中填写识别码。' + : '请使用支付宝扫码,并按页面金额完成付款。', + ]; + if ($mode === 'remark') { + $params['remark_code'] = (string) $prepared['remark_code']; + $params['tips'] = '付款备注:' . (string) $prepared['remark_code']; + } + + if ($qrcode !== '') { + $params['qrcode'] = $qrcode; + } + if ($image !== '') { + $params['qrcode_image'] = $image; + } + + return $this->payResult('page', $params, $payNo); + } + + /** + * 通道级通知定位支付单。 + * + * 这里是第一阶段,只根据 SmsForwarder 内容确认 pay_no,不做支付成功处理。 + * 后续验签、幂等、订单状态流转仍由支付服务层继续调用 notify() 完成。 + * + * @param Request $request 请求对象 + * @return array{pay_no:string} + */ + public function channelNotify(Request $request): array + { + $payload = $this->verifiedSmsForwarderPayload($request); + + return ['pay_no' => $this->locatePayNo($payload)]; + } + + /** + * 主动查单不适用于通知栏监听,保持支付中。 + * + * @param array $order 订单参数 + * @return array + */ + public function query(array $order): array + { + return [ + 'success' => true, + 'status' => PaymentPluginStatusConstant::PENDING, + 'channel_order_no' => (string) ($order['channel_order_no'] ?? $order['pay_no'] ?? ''), + 'channel_trade_no' => (string) ($order['channel_trade_no'] ?? $order['pay_no'] ?? ''), + 'message' => '个人收款监听通道等待 SmsForwarder 通知', + ]; + } + + /** + * 个人收款无上游关单接口。 + * + * @param array $order 订单参数 + * @return array + */ + public function close(array $order): array + { + return [ + 'success' => true, + 'msg' => '个人收款监听通道无需上游关单', + ]; + } + + /** + * 个人收款不支持接口退款。 + * + * @param array $order 订单参数 + * @return array + */ + public function refund(array $order): array + { + throw new PaymentException('支付宝个人收款监听不支持接口退款', 40200); + } + + /** + * 解析并校验 SmsForwarder 通知。 + * + * 这是第二阶段:服务层确认 pay_no 后调用。插件再次校验通知并恢复原始金额, + * 然后返回统一的支付成功结果给核心支付流程。 + * + * @param Request $request 回调请求 + * @return array + */ + public function notify(Request $request): array + { + $payload = $this->verifiedSmsForwarderPayload($request); + $content = (string) $payload['content']; + $tradeNo = $this->channelTradeNo($payload); + $payNo = $this->locatePayNo($payload); + $notifiedAmount = $this->receiptMatchMode() === 'amount' + ? $this->amountFromContent($content) + : null; + + $this->restoreOriginalPayAmount($payNo, $payload, $tradeNo, $notifiedAmount); + + return [ + 'status' => PaymentPluginStatusConstant::SUCCESS, + 'pay_no' => $payNo, + 'message' => mb_strcut(preg_replace('/\s+/', ' ', $content) ?? $content, 0, 180, 'UTF-8'), + 'channel_order_no' => $tradeNo, + 'channel_trade_no' => $tradeNo, + 'channel_status' => 'sms_forwarder_received', + 'paid_at' => $this->paidAtFromPayload($payload), + ]; + } + + /** + * 返回监听工具要求的成功应答。 + */ + public function notifySuccess(): string|Response + { + return 'success'; + } + + /** + * 返回监听工具要求的失败应答。 + */ + public function notifyFail(): string|Response + { + return 'fail'; + } + + /** + * 包装个人收款承接页返回值。 + * + * @param string $payPage 承接页类型 + * @param array $params 承接参数 + * @param string $payNo 支付单号 + * @return array + */ + private function payResult(string $payPage, array $params, string $payNo): array + { + return [ + 'pay_page' => $payPage, + 'pay_type' => 'alipay', + 'pay_product' => 'receipt', + 'pay_action' => 'sms_forwarder', + 'pay_params' => $params, + 'chan_order_no' => $payNo, + 'chan_trade_no' => '', + ]; + } + + /** + * 读取后台配置的订单匹配模式。 + * + * @return string 匹配模式 + */ + private function receiptMatchMode(): string + { + return (string) $this->getConfig('receipt_match_mode', 'amount') === 'remark' ? 'remark' : 'amount'; + } + + /** + * 读取识别有效期,最低 60 秒。 + * + * @return int 有效期秒数 + */ + private function receiptValidSeconds(): int + { + return max(60, (int) $this->getConfig('receipt_valid_seconds', 300)); + } + + /** + * 读取最大金额偏移,单位分。 + * + * @return int 最大金额偏移,单位分 + */ + private function amountOffsetMax(): int + { + return min(99, max(0, (int) $this->getConfig('amount_offset_max', 99))); + } + + /** + * 准备金额变动收款参数。 + * + * 在同一通道锁内扫描有效期内待支付订单,始终选择最小可用偏移金额。 + * 例如 10.01 已过期时,会重新使用 10.01,而不是继续累加到 10.04。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareAmountReceipt(string $payNo): array + { + return $this->withChannelLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $usedAmounts = $this->payOrderRepository->listUsedReceiptAmounts( + [(int) $this->getConfig('channel_id')], + $payNo, + date('Y-m-d H:i:s') + ); + + $used = array_fill_keys($usedAmounts, true); + $receiptAmount = 0; + for ($offset = 0; $offset <= $this->amountOffsetMax(); $offset++) { + $candidate = $originalAmount + $offset; + if (!isset($used[$candidate])) { + $receiptAmount = $candidate; + break; + } + } + + if ($receiptAmount <= 0) { + throw new PaymentException('当前通道可用金额偏移已用尽', 40200, [ + 'pay_no' => $payNo, + 'channel_id' => (int) $this->getConfig('channel_id'), + ]); + } + + $this->persistReceiptMeta($payOrder, [ + 'mode' => 'amount', + 'original_amount' => $originalAmount, + 'receipt_amount' => $receiptAmount, + 'offset_amount' => $receiptAmount - $originalAmount, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $receiptAmount, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 准备备注收款参数。 + * + * 为当前支付单分配 4 位备注码并写入缓存,通知时通过备注码反查 pay_no。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareRemarkReceipt(string $payNo): array + { + return $this->withChannelLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $remarkCode = $this->allocateRemarkCode($payNo); + + $this->persistReceiptMeta($payOrder, [ + 'mode' => 'remark', + 'original_amount' => $originalAmount, + 'receipt_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 加锁读取支付单。 + * + * @param string $payNo 支付单号 + * @return PayOrder + */ + private function lockedPayOrder(string $payNo): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new PaymentException('支付单不存在', 40402, ['pay_no' => $payNo]); + } + + return $payOrder; + } + + /** + * 写入个人收款元数据。 + * + * 金额模式会临时改写 pay_amount 作为识别金额,原始金额保存在 ext_json.personal_receipt。 + * + * @param PayOrder $payOrder 支付单 + * @param array $meta 收款元数据 + * @return void + */ + private function persistReceiptMeta(PayOrder $payOrder, array $meta): void + { + $payOrder->pay_amount = (int) $meta['receipt_amount']; + $payOrder->expire_at = (string) $meta['expire_at']; + $extJson = (array) ($payOrder->ext_json ?? []); + $extJson['personal_receipt'] = $meta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + } + + /** + * 获取原始订单金额。 + * + * 二次发起或刷新承接页时,优先从 ext_json 读取,避免把上一次偏移金额当成原始金额。 + * + * @param PayOrder $payOrder 支付单 + * @return int 原始订单金额,单位分 + */ + private function originalAmount(PayOrder $payOrder): int + { + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + return $originalAmount > 0 ? $originalAmount : (int) $payOrder->pay_amount; + } + + /** + * 通知识别完成后恢复支付单金额,避免变动金额进入业务单统计。 + * + * @param string $payNo 支付单号 + * @param array $payload 通知载荷 + * @param string $tradeNo 渠道交易号 + * @param int|null $notifiedAmount 通知中的实际付款金额 + * @return void + */ + private function restoreOriginalPayAmount(string $payNo, array $payload, string $tradeNo, ?int $notifiedAmount): void + { + Db::transaction(function () use ($payNo, $payload, $tradeNo, $notifiedAmount): void { + $payOrder = $this->lockedPayOrder($payNo); + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + if ($originalAmount > 0) { + $payOrder->pay_amount = $originalAmount; + } + + $receiptMeta['notified_at'] = $this->paidAtFromPayload($payload) ?? date('Y-m-d H:i:s'); + $receiptMeta['channel_trade_no'] = $tradeNo; + if ($notifiedAmount !== null) { + $receiptMeta['notified_amount'] = $notifiedAmount; + } + $extJson['personal_receipt'] = $receiptMeta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + }); + } + + /** + * 申请 4 位备注码。 + * + * 备注码缓存有效期与订单识别有效期一致,同一通道下短时间内不重复。 + * + * @param string $payNo 支付单号 + * @return string 备注码 + */ + private function allocateRemarkCode(string $payNo): string + { + for ($i = 0; $i < 30; $i++) { + $code = (string) random_int(1000, 9999); + $key = $this->remarkCacheKey($code); + if (!Cache::has($key)) { + Cache::set($key, $payNo, $this->receiptValidSeconds()); + return $code; + } + } + + throw new PaymentException('付款备注码已用尽,请稍后重试', 40200); + } + + /** + * 对同一通道的识别信息分配加锁。 + * + * 防止并发发起支付时分配到相同金额或相同备注码。 + * + * @param callable $callback 回调 + * @return mixed + */ + private function withChannelLock(callable $callback): mixed + { + $key = 'mpay_personal_receipt_lock_' . (int) $this->getConfig('channel_id'); + $token = bin2hex(random_bytes(8)); + + for ($i = 0; $i < 20; $i++) { + if (!Cache::has($key)) { + Cache::set($key, $token, 10); + } + + if ((string) Cache::get($key) === $token) { + try { + return $callback(); + } finally { + if ((string) Cache::get($key) === $token) { + Cache::delete($key); + } + } + } + usleep(50000); + } + + throw new PaymentException('当前通道正在分配收款标识,请稍后重试', 40200); + } + + /** + * 计算本次个人收款识别的过期时间。 + * + * @return string 过期时间 + */ + private function expireAt(): string + { + return date('Y-m-d H:i:s', time() + $this->receiptValidSeconds()); + } + + /** + * 校验并读取 SmsForwarder 载荷。 + * + * 签名规则按 SmsForwarder 文档:使用 timestamp、密钥和 HMAC-SHA256 校验 sign。 + * + * @param Request $request 请求对象 + * @return array + */ + private function verifiedSmsForwarderPayload(Request $request): array + { + $payload = $this->requestPayload($request); + $timestamp = trim((string) ($payload['timestamp'] ?? '')); + $sign = trim((string) ($payload['sign'] ?? '')); + $secret = (string) $this->getConfig('sms_forwarder_secret', ''); + if ($timestamp === '' || $sign === '' || $secret === '') { + throw new PaymentException('SmsForwarder 通知签名参数不完整', 40200); + } + + $timestampSeconds = (int) floor(((int) $timestamp) / 1000); + $tolerance = max(30, (int) $this->getConfig('sms_forwarder_time_tolerance', 300)); + if ($timestampSeconds <= 0 || abs(time() - $timestampSeconds) > $tolerance) { + throw new PaymentException('SmsForwarder 通知时间已失效', 40200); + } + + $expected = base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)); + if (!hash_equals($expected, rawurldecode($sign))) { + throw new PaymentException('SmsForwarder 通知签名校验失败', 40200); + } + + if (trim((string) ($payload['content'] ?? '')) === '') { + throw new PaymentException('SmsForwarder 通知内容为空', 40200); + } + + return $payload; + } + + /** + * 读取请求载荷。 + * + * Webman Request 已统一处理 query、form 和 JSON 请求体,这里直接使用 all()。 + * + * @param Request $request 请求对象 + * @return array + */ + private function requestPayload(Request $request): array + { + return (array) $request->all(); + } + + /** + * 金额模式下通过通知金额定位唯一支付单。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNoByAmount(array $payload): string + { + $amount = $this->amountFromContent((string) $payload['content']); + $orders = $this->payOrderRepository->listMutableReceiptOrdersByAmount( + [(int) $this->getConfig('channel_id')], + $amount, + 0, + date('Y-m-d H:i:s'), + ['pay_no'] + ); + + if ($orders->count() !== 1) { + throw new PaymentException('金额通知未匹配到唯一支付单', 40200, [ + 'amount' => FormatHelper::amount($amount), + 'matched_count' => $orders->count(), + ]); + } + + return (string) $orders->first()->pay_no; + } + + /** + * 根据配置选择金额匹配或备注匹配。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNo(array $payload): string + { + return $this->receiptMatchMode() === 'remark' + ? $this->locatePayNoByRemark($payload) + : $this->locatePayNoByAmount($payload); + } + + /** + * 备注模式下通过缓存中的 4 位备注码定位支付单。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNoByRemark(array $payload): string + { + $remarkCode = $this->remarkFromContent((string) $payload['content']); + $payNo = (string) Cache::get($this->remarkCacheKey($remarkCode), ''); + if ($payNo === '') { + throw new PaymentException('付款备注已失效或不存在', 40200, ['remark_code' => $remarkCode]); + } + + return $payNo; + } + + /** + * 从通知文本中提取收款金额。 + * + * @param string $content 通知内容 + * @return int 金额,单位分 + */ + private function amountFromContent(string $content): int + { + if (preg_match('/(?:收款|到账|收钱|付款|支付|转账)[^\d]{0,20}(\d+(?:\.\d{1,2})?)\s*元/u', $content, $matches) !== 1 + && preg_match('/(?moneyToCents((string) $matches[1]); + } + + /** + * 从通知文本中提取 4 位付款备注码。 + * + * @param string $content 通知内容 + * @return string 备注码 + */ + private function remarkFromContent(string $content): string + { + if (preg_match('/(?:备注|留言|附言|付款备注|收款备注)[::\s]*([0-9]{4})/u', $content, $matches) !== 1) { + throw new PaymentException('通知内容未识别到付款备注', 40200); + } + + return (string) $matches[1]; + } + + /** + * 将金额文本转换为分。 + * + * @param string $money 金额文本 + * @return int 金额,单位分 + */ + private function moneyToCents(string $money): int + { + if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + throw new PaymentException('通知金额格式不合法', 40200, ['money' => $money]); + } + + [$integer, $fraction] = array_pad(explode('.', $money, 2), 2, ''); + return (int) $integer * 100 + (int) str_pad(substr($fraction, 0, 2), 2, '0'); + } + + /** + * 生成备注码缓存键。 + * + * @param string $code 备注码 + * @return string 缓存键 + */ + private function remarkCacheKey(string $code): string + { + return 'mpay_personal_receipt_remark_' . (int) $this->getConfig('channel_id') . '_' . $code; + } + + /** + * 为 SmsForwarder 通知生成稳定的渠道交易号。 + * + * @param array $payload 通知载荷 + * @return string 渠道交易号 + */ + private function channelTradeNo(array $payload): string + { + return 'SF' . substr(hash('sha256', (string) ($payload['from'] ?? '') . '|' . (string) $payload['timestamp'] . '|' . (string) $payload['content']), 0, 30); + } + + /** + * 从 SmsForwarder 毫秒时间戳提取支付时间。 + * + * @param array $payload 通知载荷 + * @return string|null 支付时间 + */ + private function paidAtFromPayload(array $payload): ?string + { + $timestamp = (int) ($payload['timestamp'] ?? 0); + return $timestamp > 0 ? date('Y-m-d H:i:s', (int) floor($timestamp / 1000)) : null; + } + +} diff --git a/app/common/payment/EpayV1Payment.php b/app/common/payment/EpayV1Payment.php index fd038f7..1349f5b 100644 --- a/app/common/payment/EpayV1Payment.php +++ b/app/common/payment/EpayV1Payment.php @@ -6,6 +6,8 @@ namespace app\common\payment; use app\common\base\BasePayment; use app\common\constant\AuthConstant; +use app\common\constant\EpayProtocolConstant; +use app\common\constant\NotifyConstant; use app\common\constant\PaymentPluginStatusConstant; use app\common\interface\PaymentInterface; use app\common\interface\PayPluginInterface; @@ -18,18 +20,29 @@ use support\Response; /** * ePay V1 网关插件。 * - * 适用于对接仍提供 V1 协议的第三方平台。 + * 对接 docs/api/epay/epay_v1.md 中的 submit.php、mapi.php、api.php 和 notify_url 协议。 + * V1 使用 MD5 签名,页面跳转支付与 mapi 接口支付共用同一套基础参数。 */ class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginInterface { + /** + * ePay 协议签名管理器。 + * + * 通过容器懒加载,避免插件元信息读取阶段提前初始化签名服务。 + */ private ?EpaySignerManager $epaySignerManager = null; /** + * 插件元信息和后台配置表单。 + * + * V1 只需要网关地址、商户 pid、MD5 密钥和 mapi 开关。页面跳转支付不依赖 mapi; + * 当通道关闭 mapi 时,收银台仍可以通过 submit.php 跳转到上游页面。 + * * @var array */ protected array $paymentInfo = [ 'code' => 'epay_v1', - 'name' => 'ePay V1 网关', + 'name' => '彩虹易支付V1', 'author' => 'MPAY', 'version' => '1.0.0', 'pay_types' => ['alipay', 'wxpay'], @@ -37,103 +50,159 @@ class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginIn 'config_schema' => [ [ 'type' => 'input', - 'field' => 'gateway_url', - 'title' => '上游网关地址', + 'field' => 'api_url', + 'title' => '接口地址', 'value' => '', 'props' => [ - 'placeholder' => '例如:https://pay.example.com', + 'placeholder' => '例如:https://pay.example.com/', ], 'validate' => [ - ['required' => true, 'message' => '上游网关地址不能为空'], + ['required' => true, 'message' => '接口地址不能为空'], ], ], [ 'type' => 'input', - 'field' => 'upstream_pid', - 'title' => '上游商户ID', + 'field' => 'pid', + 'title' => '商户ID', 'value' => '', 'props' => [ - 'placeholder' => '请输入第三方平台分配的 pid', + 'placeholder' => '商户pid', ], 'validate' => [ - ['required' => true, 'message' => '上游商户ID不能为空'], + ['required' => true, 'message' => '商户ID不能为空'], ], ], [ - 'type' => 'textarea', - 'field' => 'upstream_key', - 'title' => '上游 MD5 密钥', + 'type' => 'input', + 'field' => 'api_key', + 'title' => '商户密钥', 'value' => '', 'props' => [ - 'placeholder' => '请输入第三方平台分配的 API Key / KEY', + 'placeholder' => 'MD5格式密钥', 'rows' => 4, ], 'validate' => [ - ['required' => true, 'message' => '上游 MD5 密钥不能为空'], + ['required' => true, 'message' => 'MD5密钥不能为空'], ], ], [ - 'type' => 'input', - 'field' => 'pay_path', - 'title' => '下单路径', - 'value' => '/mapi.php', + 'type' => 'switch', + 'field' => 'support_mapi', + 'title' => '是否支持mapi接口', + 'value' => true, 'props' => [ - 'placeholder' => '默认 /mapi.php', - ], - ], - [ - 'type' => 'input', - 'field' => 'api_path', - 'title' => '查询/退款路径', - 'value' => '/api.php', - 'props' => [ - 'placeholder' => '默认 /api.php', - ], - ], - [ - 'type' => 'textarea', - 'field' => 'type_mapping_json', - 'title' => '支付方式映射', - 'value' => "{\n \"alipay\": \"alipay\",\n \"wxpay\": \"wxpay\"\n}", - 'props' => [ - 'placeholder' => 'JSON 格式,例如 {\"wxpay\":\"wxpay\"}', - 'rows' => 5, + 'checkedText' => '支持', + 'uncheckedText' => '不支持', ], ], ], ]; - public function init(array $channelConfig): void - { - parent::init($channelConfig); - } - + /** + * 发起 V1 支付。 + * + * 根据订单里的 `_submit_type` 决定走 submit.php 页面跳转还是 mapi.php 接口创建。 + * 两种方式都会返回统一的插件下单结果,方便支付服务层继续处理收银台承接。 + * + * @param array $order 标准插件下单参数 + * @return array + */ public function pay(array $order): array { $payload = [ - 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), - 'type' => $this->resolveUpstreamType($order, [ - 'alipay' => 'alipay', - 'wxpay' => 'wxpay', - ]), - 'out_trade_no' => $this->resolveOrderNo($order), - 'notify_url' => trim((string) ($order['callback_url'] ?? '')), - 'return_url' => trim((string) ($order['return_url'] ?? '')), - 'name' => $this->resolveSubject($order), - 'money' => $this->amountToMoney($this->resolveAmount($order)), - 'clientip' => trim((string) ($order['client_ip'] ?? '127.0.0.1')), - 'device' => $this->resolveDevice($order), + 'pid' => (string) $this->getConfig('pid'), + 'type' => (string) $order['pay_type_code'], + 'out_trade_no' => (string) $order['pay_no'], + 'notify_url' => (string) $order['callback_url'], + 'name' => mb_strcut((string) $order['subject'], 0, 127, 'UTF-8'), + 'money' => FormatHelper::amount((int) $order['amount']), ]; - $param = $this->resolveParamValue($order); + $returnUrl = (string) ($order['return_url'] ?? ''); + if ($returnUrl !== '') { + $payload['return_url'] = $returnUrl; + } + + $param = (string) ($order['extra']['merchant']['param'] ?? ''); if ($param !== '') { $payload['param'] = $param; } - $payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_MD5, $this->requireConfigValue('upstream_key', '上游 MD5 密钥')); - $response = $this->isMockEnabled() - ? $this->buildMockPayResponse($payload, $order) - : $this->requestFormJson('POST', $this->resolveGatewayUrl('pay_path', '/mapi.php'), $payload); + if ((string) $order['extra']['_submit_type'] === EpayProtocolConstant::SUBMIT_TYPE_PAGE) { + return $this->submitPay($payload); + } + + return $this->mapiPay($payload, $order); + } + + /** + * 使用 V1 submit.php 页面跳转支付。 + * + * submit.php 是浏览器跳转协议,发起时拿不到上游平台单号,因此先使用本地支付单号占位。 + * 真正的上游 trade_no 会在异步回调或后续查单时补齐。 + * + * @param array $payload V1 支付参数 + * @return array + */ + private function submitPay(array $payload): array + { + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_MD5; + $payload['sign'] = $this->signerManager()->sign( + $payload, + AuthConstant::API_SIGN_NAME_MD5, + (string) $this->getConfig('api_key') + ); + + $action = $this->gatewayUrl('/submit.php'); + $query = http_build_query($payload, '', '&', PHP_QUERY_RFC3986); + $url = $action . (str_contains($action, '?') ? '&' : '?') . $query; + + return [ + 'pay_page' => 'jump', + 'pay_type' => (string) $payload['type'], + 'pay_product' => (string) $payload['type'], + 'pay_action' => 'submitPay', + 'pay_params' => [ + 'url' => $url, + 'action' => $action, + 'method' => 'get', + 'payload' => $payload, + ], + // 页面跳转阶段还拿不到上游真实单号,先用本地 out_trade_no/pay_no 占位。 + 'chan_order_no' => (string) $payload['out_trade_no'], + 'chan_trade_no' => '', + ]; + } + + /** + * 使用 V1 mapi.php 接口支付。 + * + * mapi.php 会直接返回二维码、跳转链接或 URL Scheme,并返回平台订单号。 + * 如果上游没有返回有效承接内容或 trade_no,按创建订单失败处理。 + * + * @param array $payload V1 支付参数 + * @param array $order 标准插件下单参数 + * @return array + */ + private function mapiPay(array $payload, array $order): array + { + if (in_array($this->getConfig('support_mapi', true), [false, 0, '0'], true)) { + throw new PaymentException('当前通道未开启 mapi 接口', 40200); + } + + $payload['clientip'] = (string) $order['client_ip']; + $payload['device'] = (string) $order['_env']; + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_MD5; + $payload['sign'] = $this->signerManager()->sign( + $payload, + AuthConstant::API_SIGN_NAME_MD5, + (string) $this->getConfig('api_key') + ); + + $response = $this->requestJson('POST', $this->gatewayUrl('/mapi.php'), [ + 'form_params' => $payload, + 'headers' => ['Accept' => 'application/json'], + ]); if ((int) ($response['code'] ?? 0) !== 1) { throw new PaymentException((string) ($response['msg'] ?? '上游 V1 下单失败'), 40200, [ @@ -141,38 +210,96 @@ class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginIn ]); } - $channelNos = $this->resolveChannelNos($response + [ - 'trade_no' => (string) ($response['trade_no'] ?? $payload['out_trade_no']), - ]); - $payParams = $this->normalizePayResponse($response); + $payPage = ''; + if ((string) ($response['payurl'] ?? '') !== '') { + $payPage = 'jump'; + } elseif ((string) ($response['qrcode'] ?? '') !== '') { + $payPage = 'qrcode'; + } elseif ((string) ($response['urlscheme'] ?? '') !== '') { + $payPage = 'urlscheme'; + } + + if ($payPage === '') { + throw new PaymentException('上游 V1 未返回有效支付内容', 40200, [ + 'response' => $response, + ]); + } + + $channelOrderNo = (string) ($response['trade_no'] ?? ''); + if ($channelOrderNo === '') { + throw new PaymentException('上游 V1 未返回平台订单号', 40200, [ + 'response' => $response, + ]); + } return [ - 'pay_product' => (string) $payload['type'], - 'pay_action' => (string) ($payParams['type'] ?? ''), - 'pay_params' => $payParams, - 'chan_order_no' => $channelNos['channel_order_no'], - 'chan_trade_no' => $channelNos['channel_trade_no'], + 'pay_page' => $payPage, + 'pay_type' => $payload['type'], + 'pay_product' => $payload['type'], + 'pay_action' => 'mapiPay', + 'pay_params' => $this->mapiPayParams($payPage, $response), + 'chan_order_no' => $channelOrderNo, + 'chan_trade_no' => '', ]; } + /** + * 按承接页组件固定字段包装 V1 mapi 原始支付参数。 + * + * @param string $payPage 承接页类型 + * @param array $response V1 mapi 原始响应 + * @return array + */ + private function mapiPayParams(string $payPage, array $response): array + { + return match ($payPage) { + 'qrcode' => [ + 'qrcode' => (string) $response['qrcode'], + 'raw' => $response, + ], + 'jump' => [ + 'url' => (string) $response['payurl'], + 'raw' => $response, + ], + 'urlscheme' => [ + 'urlscheme' => (string) $response['urlscheme'], + 'raw' => $response, + ], + default => [ + 'raw' => $response, + ], + }; + } + + /** + * 查询 V1 支付订单。 + * + * 优先使用上游 trade_no 查询;页面跳转阶段没有 trade_no 时,退回使用本地 out_trade_no。 + * + * @param array $order 标准插件查单参数 + * @return array + */ public function query(array $order): array { $payload = [ 'act' => 'order', - 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), - 'key' => $this->requireConfigValue('upstream_key', '上游 MD5 密钥'), + 'pid' => (string) $this->getConfig('pid'), + 'key' => (string) $this->getConfig('api_key'), ]; - $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); - if ($tradeNo !== '') { - $payload['trade_no'] = $tradeNo; + $payNo = (string) ($order['pay_no'] ?? ''); + $channelOrderNo = (string) ($order['chan_order_no'] ?? ''); + if ($channelOrderNo !== '' && $channelOrderNo !== $payNo) { + $payload['trade_no'] = $channelOrderNo; } else { - $payload['out_trade_no'] = $this->resolveOrderNo($order); + $payload['out_trade_no'] = $payNo; } - $response = $this->isMockEnabled() - ? $this->buildMockQueryResponse($order) - : $this->requestQueryJson($this->resolveGatewayUrl('api_path', '/api.php'), $payload); + $response = $this->requestJson('GET', $this->gatewayUrl('/api.php'), [ + 'query' => $payload, + 'headers' => ['Accept' => 'application/json'], + ]); + if ((int) ($response['code'] ?? 0) !== 1) { return [ 'success' => false, @@ -181,7 +308,6 @@ class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginIn ]; } - $channelNos = $this->resolveChannelNos($response); $status = (int) ($response['status'] ?? 0) === 1 ? PaymentPluginStatusConstant::SUCCESS : PaymentPluginStatusConstant::PENDING; @@ -189,90 +315,121 @@ class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginIn return [ 'success' => true, 'status' => $status, - 'channel_order_no' => $channelNos['channel_order_no'], - 'channel_trade_no' => $channelNos['channel_trade_no'], + 'channel_order_no' => (string) ($response['trade_no'] ?? $channelOrderNo), + 'channel_trade_no' => (string) ($response['api_trade_no'] ?? ''), 'channel_status' => (string) ($response['status'] ?? ''), - 'paid_at' => $response['endtime'] ?? null, - 'ext_json' => [ - 'channel_response' => $response, - ], + 'message' => (string) ($response['msg'] ?? ''), + 'paid_at' => $status === PaymentPluginStatusConstant::SUCCESS ? ($response['endtime'] ?? null) : null, ]; } + /** + * V1 协议没有关单接口。 + * + * @param array $order 标准插件关单参数 + * @return array + */ public function close(array $order): array { throw new PaymentException('上游 ePay V1 协议不支持关单', 40200, [ 'plugin_code' => $this->getCode(), - 'order_no' => $this->resolveOrderNo($order), + 'pay_no' => (string) ($order['pay_no'] ?? ''), ]); } + /** + * 提交 V1 订单退款。 + * + * 与查单一样,优先使用上游 trade_no;没有时使用 out_trade_no。 + * + * @param array $order 标准插件退款参数 + * @return array + */ public function refund(array $order): array { $payload = [ - 'act' => 'refund', - 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), - 'key' => $this->requireConfigValue('upstream_key', '上游 MD5 密钥'), - 'money' => $this->amountToMoney((int) ($order['refund_amount'] ?? 0)), + 'pid' => (string) $this->getConfig('pid'), + 'key' => (string) $this->getConfig('api_key'), + 'money' => FormatHelper::amount((int) $order['refund_amount']), ]; - $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); - if ($tradeNo !== '') { - $payload['trade_no'] = $tradeNo; + $payNo = (string) ($order['pay_no'] ?? ''); + $channelOrderNo = (string) ($order['chan_order_no'] ?? ''); + if ($channelOrderNo !== '' && $channelOrderNo !== $payNo) { + $payload['trade_no'] = $channelOrderNo; } else { - $payload['out_trade_no'] = $this->resolveOrderNo($order); + $payload['out_trade_no'] = $payNo; } - $response = $this->isMockEnabled() - ? $this->buildMockRefundResponse($order) - : $this->requestFormJson('POST', $this->resolveGatewayUrl('api_path', '/api.php'), $payload); - if ((int) ($response['code'] ?? 0) !== 1) { - return [ - 'success' => false, - 'msg' => (string) ($response['msg'] ?? '上游 V1 退款失败'), - 'raw_data' => $response, - ]; + $refundNo = (string) ($order['refund_no'] ?? ''); + if ($refundNo !== '') { + $payload['refund_no'] = $refundNo; } + $response = $this->requestJson('POST', $this->gatewayUrl('/api.php?act=refund'), [ + 'form_params' => $payload, + 'headers' => ['Accept' => 'application/json'], + ]); + return [ - 'success' => true, - 'msg' => (string) ($response['msg'] ?? 'success'), - 'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['trade_no'] ?? '')), + 'success' => (int) ($response['code'] ?? 0) === 1, + 'msg' => (string) ($response['msg'] ?? ''), + 'chan_refund_no' => (string) ($response['refund_no'] ?? $response['trade_no'] ?? ''), 'raw_data' => $response, ]; } + /** + * 解析并验签 V1 支付回调。 + * + * V1 回调参数通过 MD5 验签,验签通过后由 trade_status 映射为平台内部支付状态。 + * + * @param Request $request 回调请求 + * @return array + */ public function notify(Request $request): array { - $payload = $this->resolveNotifyPayload($request); - $this->verifyPayloadSignature( + $payload = (array) $request->all(); + $sign = (string) ($payload['sign'] ?? ''); + if ($sign === '' || !$this->signerManager()->verify( $payload, AuthConstant::API_SIGN_NAME_MD5, - $this->requireConfigValue('upstream_key', '上游 MD5 密钥'), - '上游 V1 回调验签失败' - ); + $sign, + (string) $this->getConfig('api_key') + )) { + throw new PaymentException('上游 V1 回调验签失败', 40200); + } - $channelNos = $this->resolveChannelNos($payload); - $status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? '')); + $channelOrderNo = (string) ($payload['trade_no'] ?? ''); + if ($channelOrderNo === '') { + throw new PaymentException('上游 V1 回调缺少平台订单号', 40200); + } + + $tradeStatus = strtoupper((string) ($payload['trade_status'] ?? '')); + $status = $tradeStatus === NotifyConstant::EPAY_TRADE_STATUS_SUCCESS + ? PaymentPluginStatusConstant::SUCCESS + : PaymentPluginStatusConstant::PENDING; return [ 'status' => $status, - 'message' => (string) ($payload['trade_status'] ?? ''), - 'channel_order_no' => $channelNos['channel_order_no'], - 'channel_trade_no' => $channelNos['channel_trade_no'], - 'channel_status' => (string) ($payload['trade_status'] ?? ''), - 'paid_at' => $payload['endtime'] ?? null, - 'ext_json' => [ - 'channel_type' => (string) ($payload['type'] ?? ''), - ], + 'message' => $tradeStatus, + 'channel_order_no' => $channelOrderNo, + 'channel_trade_no' => $channelOrderNo, + 'channel_status' => $tradeStatus, ]; } + /** + * 返回上游要求的成功应答。 + */ public function notifySuccess(): string|Response { return 'success'; } + /** + * 返回上游要求的失败应答。 + */ public function notifyFail(): string|Response { return 'fail'; @@ -285,7 +442,7 @@ class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginIn { if ($this->epaySignerManager === null) { /** @var EpaySignerManager $manager */ - $manager = container_make(EpaySignerManager::class, []); + $manager = container_get(EpaySignerManager::class); $this->epaySignerManager = $manager; } @@ -293,506 +450,35 @@ class EpayV1Payment extends BasePayment implements PaymentInterface, PayPluginIn } /** - * 是否启用插件内置 mock。 + * 拼接上游网关地址。 + * + * 后台配置只保存根地址,具体协议路径由插件内部统一补齐。 */ - private function isMockEnabled(): bool + private function gatewayUrl(string $path): string { - $value = $this->getConfig('mock_enabled', false); - if (is_bool($value)) { - return $value; - } - - if (is_numeric($value)) { - return (int) $value === 1; - } - - $filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); - return $filtered ?? false; + return rtrim((string) $this->getConfig('api_url'), '/') . '/' . ltrim($path, '/'); } /** - * 读取必填配置。 - */ - private function requireConfigValue(string $key, string $label): string - { - $value = trim((string) $this->getConfig($key, '')); - if ($value === '') { - throw new PaymentException($label . '未配置', 40200, [ - 'config_key' => $key, - ]); - } - - return $value; - } - - /** - * 构建上游接口地址。 - */ - private function resolveGatewayUrl(string $pathConfigKey, string $defaultPath): string - { - $baseUrl = rtrim($this->requireConfigValue('gateway_url', '上游网关地址'), '/'); - $path = trim((string) $this->getConfig($pathConfigKey, $defaultPath)); - if ($path === '') { - $path = $defaultPath; - } - - if (preg_match('/^https?:\/\//i', $path) === 1) { - return rtrim($path, '/'); - } - - return $baseUrl . '/' . ltrim($path, '/'); - } - - /** - * @param array $payload + * 发送上游 HTTP 请求并解析 JSON 响应。 + * + * @param string $method 请求方法 + * @param string $url 请求地址 + * @param array $options HTTP 请求选项 * @return array */ - private function requestFormJson(string $method, string $url, array $payload): array - { - $response = $this->request($method, $url, [ - 'form_params' => $payload, - 'headers' => [ - 'Accept' => 'application/json', - ], - ]); - - return $this->decodeJsonResponse((string) $response->getBody(), $url); - } - - /** - * @param array $query - * @return array - */ - private function requestQueryJson(string $url, array $query): array - { - $response = $this->request('GET', $url, [ - 'query' => $query, - 'headers' => [ - 'Accept' => 'application/json', - ], - ]); - - return $this->decodeJsonResponse((string) $response->getBody(), $url); - } - - /** - * @return array - */ - private function decodeJsonResponse(string $body, string $url): array + private function requestJson(string $method, string $url, array $options): array { + $response = $this->request($method, $url, $options); + $body = (string) $response->getBody(); $decoded = json_decode($body, true); if (!is_array($decoded)) { throw new PaymentException('上游网关响应不是合法 JSON', 40200, [ 'url' => $url, - 'body_excerpt' => $this->clipText($body), + 'body_excerpt' => mb_strcut(preg_replace('/\s+/', ' ', $body) ?? $body, 0, 240, 'UTF-8'), ]); } return $decoded; } - - /** - * @param array $payload - * @return array - */ - private function signPayload(array $payload, string $signType, string $key): array - { - $payload['sign_type'] = $signType; - $payload['sign'] = $this->signerManager()->sign($payload, $signType, $key); - - return $payload; - } - - /** - * @param array $payload - */ - private function verifyPayloadSignature(array $payload, string $defaultSignType, string $key, string $message): void - { - $sign = trim((string) ($payload['sign'] ?? '')); - if ($sign === '') { - throw new PaymentException($message, 40200, ['reason' => 'missing_sign']); - } - - $signType = trim((string) ($payload['sign_type'] ?? $defaultSignType)); - if (!$this->signerManager()->verify($payload, $signType, $sign, $key)) { - throw new PaymentException($message, 40200, [ - 'sign_type' => $signType, - ]); - } - } - - /** - * @param array $defaultMapping - * @return array - */ - private function resolveTypeMapping(array $defaultMapping): array - { - $raw = $this->getConfig('type_mapping_json', ''); - $mapping = $defaultMapping; - - if (is_array($raw)) { - foreach ($raw as $key => $value) { - $source = strtolower(trim((string) $key)); - $target = strtolower(trim((string) $value)); - if ($source !== '' && $target !== '') { - $mapping[$source] = $target; - } - } - - return $mapping; - } - - $text = trim((string) $raw); - if ($text === '') { - return $mapping; - } - - $decoded = json_decode($text, true); - if (!is_array($decoded)) { - throw new PaymentException('支付方式映射配置不是合法 JSON', 40200, [ - 'config_key' => 'type_mapping_json', - ]); - } - - foreach ($decoded as $key => $value) { - $source = strtolower(trim((string) $key)); - $target = strtolower(trim((string) $value)); - if ($source !== '' && $target !== '') { - $mapping[$source] = $target; - } - } - - return $mapping; - } - - /** - * @param array $defaultMapping - */ - private function resolveUpstreamType(array $order, array $defaultMapping): string - { - $payTypeCode = strtolower(trim((string) ($order['pay_type_code'] ?? ''))); - if ($payTypeCode === '') { - throw new PaymentException('订单缺少支付方式编码', 40200); - } - - $mapping = $this->resolveTypeMapping($defaultMapping); - $upstreamType = strtolower(trim((string) ($mapping[$payTypeCode] ?? ''))); - if ($upstreamType === '') { - throw new PaymentException('未配置上游支付方式映射', 40200, [ - 'pay_type_code' => $payTypeCode, - ]); - } - - return $upstreamType; - } - - /** - * 获取平台内部支付单号,作为上游商户订单号。 - */ - private function resolveOrderNo(array $order): string - { - $orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? '')); - if ($orderNo === '') { - throw new PaymentException('订单缺少订单号', 40200); - } - - return $orderNo; - } - - /** - * 获取支付金额(分)。 - */ - private function resolveAmount(array $order): int - { - $amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? 0); - if ($amount <= 0) { - throw new PaymentException('订单金额不合法', 40200); - } - - return $amount; - } - - /** - * 获取订单标题。 - */ - private function resolveSubject(array $order): string - { - $subject = trim((string) ($order['subject'] ?? $order['body'] ?? '')); - if ($subject === '') { - throw new PaymentException('订单标题不能为空', 40200); - } - - if (function_exists('mb_strcut')) { - return mb_strcut($subject, 0, 127, 'UTF-8'); - } - - return substr($subject, 0, 127); - } - - /** - * @return array - */ - private function resolveExtraContext(array $order): array - { - $context = []; - foreach ([$order['extra'] ?? null, $order['param'] ?? null] as $bag) { - if (is_array($bag)) { - $context = array_merge($context, $bag); - foreach (['merchant', 'payment', 'source'] as $section) { - if (isset($bag[$section]) && is_array($bag[$section])) { - $context = array_merge($context, $bag[$section]); - } - } - continue; - } - - if (!is_string($bag)) { - continue; - } - - $text = trim($bag); - if ($text === '') { - continue; - } - - $decoded = json_decode($text, true); - if (is_array($decoded)) { - $context = array_merge($context, $decoded); - continue; - } - - parse_str($text, $parsed); - if (is_array($parsed) && $parsed !== []) { - $context = array_merge($context, $parsed); - } - } - - return $context; - } - - /** - * 归一化备注透传字段。 - */ - private function resolveParamValue(array $order): string - { - $context = $this->resolveExtraContext($order); - $param = $context['param'] ?? null; - if ($param === null || $param === '') { - return ''; - } - - if (is_scalar($param)) { - return trim((string) $param); - } - - $json = json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - return $json !== false ? $json : ''; - } - - /** - * 归一化客户端环境。 - */ - private function resolveDevice(array $order, string $default = 'pc'): string - { - $device = strtolower(trim((string) ($order['_env'] ?? $order['device'] ?? ''))); - return $device !== '' ? $device : $default; - } - - /** - * 提取回调入参。 - * - * @return array - */ - private function resolveNotifyPayload(Request $request): array - { - $payload = array_merge((array) $request->get(), (array) $request->post()); - if ($payload !== []) { - return $payload; - } - - $all = $request->all(); - - return is_array($all) ? $all : []; - } - - /** - * 将分转换为元字符串。 - */ - private function amountToMoney(int $amount): string - { - return FormatHelper::amount($amount); - } - - /** - * @return array{channel_order_no: string, channel_trade_no: string} - */ - private function resolveChannelNos(array $payload): array - { - $channelOrderNo = trim((string) ($payload['trade_no'] ?? $payload['transaction_id'] ?? '')); - $channelTradeNo = trim((string) ($payload['api_trade_no'] ?? $payload['channel_trade_no'] ?? '')); - - if ($channelOrderNo === '' && $channelTradeNo === '') { - throw new PaymentException('上游返回缺少渠道订单号', 40200); - } - - if ($channelOrderNo === '') { - $channelOrderNo = $channelTradeNo; - } - if ($channelTradeNo === '') { - $channelTradeNo = $channelOrderNo; - } - - return [ - 'channel_order_no' => $channelOrderNo, - 'channel_trade_no' => $channelTradeNo, - ]; - } - - /** - * 归一化回调支付状态。 - */ - private function normalizeNotifyStatus(string $tradeStatus): string - { - $tradeStatus = strtoupper(trim($tradeStatus)); - if (in_array($tradeStatus, ['TRADE_SUCCESS', 'SUCCESS', 'PAY_SUCCESS', 'FINISHED'], true)) { - return PaymentPluginStatusConstant::SUCCESS; - } - - if (in_array($tradeStatus, ['TRADE_FAIL', 'FAILED', 'TRADE_CLOSED', 'CLOSED', 'PAYERROR'], true)) { - return PaymentPluginStatusConstant::FAILED; - } - - return PaymentPluginStatusConstant::PENDING; - } - - /** - * 生成响应文本摘要。 - */ - private function clipText(string $text, int $length = 240): string - { - $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text); - if ($text === '') { - return ''; - } - - return strlen($text) <= $length ? $text : substr($text, 0, $length) . '...'; - } - - /** - * @param array $payload - * @param array $order - * @return array - */ - private function buildMockPayResponse(array $payload, array $order): array - { - $seed = strtolower((string) ($payload['out_trade_no'] ?? $this->resolveOrderNo($order))); - $channelOrderNo = 'V1ORD' . strtoupper(substr(md5($seed), 0, 16)); - $channelTradeNo = 'V1TRD' . strtoupper(substr(sha1($seed), 0, 16)); - - return [ - 'code' => 1, - 'msg' => 'success', - 'trade_no' => $channelOrderNo, - 'api_trade_no' => $channelTradeNo, - 'payurl' => $this->resolveMockJumpUrl($channelTradeNo), - ]; - } - - /** - * @param array $order - * @return array - */ - private function buildMockQueryResponse(array $order): array - { - $channelOrderNo = trim((string) ($order['chan_order_no'] ?? '')); - $channelTradeNo = trim((string) ($order['chan_trade_no'] ?? '')); - if ($channelOrderNo === '' && $channelTradeNo === '') { - $seed = strtolower($this->resolveOrderNo($order)); - $channelOrderNo = 'V1ORD' . strtoupper(substr(md5($seed), 0, 16)); - $channelTradeNo = 'V1TRD' . strtoupper(substr(sha1($seed), 0, 16)); - } elseif ($channelOrderNo === '') { - $channelOrderNo = $channelTradeNo; - } elseif ($channelTradeNo === '') { - $channelTradeNo = $channelOrderNo; - } - - return [ - 'code' => 1, - 'msg' => '查询订单成功', - 'trade_no' => $channelOrderNo, - 'api_trade_no' => $channelTradeNo, - 'out_trade_no' => $this->resolveOrderNo($order), - 'status' => 1, - 'buyer' => 'MOCK_V1_BUYER', - 'param' => $this->resolveParamValue($order), - 'endtime' => date('Y-m-d H:i:s'), - ]; - } - - /** - * @param array $order - * @return array - */ - private function buildMockRefundResponse(array $order): array - { - $seed = strtolower(trim((string) ($order['refund_no'] ?? $this->resolveOrderNo($order)))); - - return [ - 'code' => 1, - 'msg' => '退款成功', - 'refund_no' => 'V1REF' . strtoupper(substr(md5($seed), 0, 16)), - ]; - } - - /** - * 构建 mock 跳转地址。 - */ - private function resolveMockJumpUrl(string $channelTradeNo): string - { - $baseUrl = trim((string) $this->getConfig('mock_jump_base_url', 'https://mock.epay.test/pay/v1')); - if ($baseUrl === '') { - $baseUrl = 'https://mock.epay.test/pay/v1'; - } - - return rtrim($baseUrl, '/') . '?trade_no=' . rawurlencode($channelTradeNo); - } - - /** - * @param array $response - * @return array - */ - private function normalizePayResponse(array $response): array - { - $payUrl = trim((string) ($response['payurl'] ?? '')); - if ($payUrl !== '') { - return [ - 'type' => 'jump', - 'payurl' => $payUrl, - 'redirect_url' => $payUrl, - ]; - } - - $qrcode = trim((string) ($response['qrcode'] ?? '')); - if ($qrcode !== '') { - return [ - 'type' => 'qrcode', - 'qrcode' => $qrcode, - 'qrcode_text' => $qrcode, - ]; - } - - $urlscheme = trim((string) ($response['urlscheme'] ?? '')); - if ($urlscheme !== '') { - return [ - 'type' => 'urlscheme', - 'urlscheme' => $urlscheme, - 'redirect_url' => $urlscheme, - ]; - } - - throw new PaymentException('上游 V1 未返回有效支付内容', 40200, [ - 'response' => $response, - ]); - } } diff --git a/app/common/payment/EpayV2Payment.php b/app/common/payment/EpayV2Payment.php index 6b51b39..d1d358a 100644 --- a/app/common/payment/EpayV2Payment.php +++ b/app/common/payment/EpayV2Payment.php @@ -6,9 +6,12 @@ namespace app\common\payment; use app\common\base\BasePayment; use app\common\constant\AuthConstant; +use app\common\constant\EpayProtocolConstant; +use app\common\constant\NotifyConstant; use app\common\constant\PaymentPluginStatusConstant; use app\common\interface\PaymentInterface; use app\common\interface\PayPluginInterface; +use app\common\interface\TransferPluginInterface; use app\common\util\FormatHelper; use app\exception\PaymentException; use app\service\payment\epay\EpaySignerManager; @@ -18,178 +21,194 @@ use support\Response; /** * ePay V2 网关插件。 * - * 适用于对接已升级为 V2 协议的第三方平台。 + * 对接 docs/api/epay/epay_v2.md 中的支付、退款、查单、回调和转账协议。 + * V2 使用 RSA 签名,所有接口响应都需要验签后才能交给业务服务层。 */ -class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginInterface +class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginInterface, TransferPluginInterface { + /** + * ePay 协议签名管理器。 + * + * 通过容器懒加载,避免插件元信息读取阶段提前初始化签名服务。 + */ private ?EpaySignerManager $epaySignerManager = null; /** + * 插件元信息和后台配置表单。 + * + * V2 需要商户私钥和平台公钥。`support_api` 只控制 API 创建订单能力; + * 关闭后仍可使用页面跳转支付,查单、关单、退款和转账接口保持协议能力不变。 + * * @var array */ protected array $paymentInfo = [ 'code' => 'epay_v2', - 'name' => 'ePay V2 网关', + 'name' => '彩虹易支付V2', 'author' => 'MPAY', 'version' => '1.0.0', - 'pay_types' => ['alipay', 'wxpay', 'unionpay'], - 'transfer_types' => [], + 'pay_types' => ['alipay', 'wxpay', 'qqpay'], + 'transfer_types' => ['alipay', 'wxpay', 'qqpay', 'bank'], 'config_schema' => [ [ 'type' => 'input', - 'field' => 'gateway_url', - 'title' => '上游网关地址', + 'field' => 'api_url', + 'title' => '接口地址', 'value' => '', 'props' => [ - 'placeholder' => '例如:https://pay.example.com', + 'placeholder' => '例如:https://pay.example.com/', ], 'validate' => [ - ['required' => true, 'message' => '上游网关地址不能为空'], + ['required' => true, 'message' => '接口地址不能为空'], ], ], [ 'type' => 'input', - 'field' => 'upstream_pid', - 'title' => '上游商户ID', + 'field' => 'pid', + 'title' => '商户ID', 'value' => '', 'props' => [ - 'placeholder' => '请输入第三方平台分配的 pid', + 'placeholder' => '商户pid', ], 'validate' => [ - ['required' => true, 'message' => '上游商户ID不能为空'], + ['required' => true, 'message' => '商户ID不能为空'], ], ], [ 'type' => 'textarea', 'field' => 'merchant_private_key', - 'title' => '上游商户私钥', + 'title' => '商户私钥', 'value' => '', 'props' => [ - 'placeholder' => '请输入对接上游 V2 的商户 RSA 私钥', + 'placeholder' => 'RSA 商户私钥', 'rows' => 6, ], 'validate' => [ - ['required' => true, 'message' => '上游商户私钥不能为空'], + ['required' => true, 'message' => '商户私钥不能为空'], ], ], [ 'type' => 'textarea', 'field' => 'platform_public_key', - 'title' => '上游平台公钥', + 'title' => '平台公钥', 'value' => '', 'props' => [ - 'placeholder' => '请输入上游平台 RSA 公钥', + 'placeholder' => 'RSA 平台公钥', 'rows' => 6, ], 'validate' => [ - ['required' => true, 'message' => '上游平台公钥不能为空'], + ['required' => true, 'message' => '平台公钥不能为空'], ], ], [ - 'type' => 'input', - 'field' => 'create_path', - 'title' => '下单路径', - 'value' => '/api/pay/create', + 'type' => 'switch', + 'field' => 'support_api', + 'title' => '是否支持API创建订单', + 'value' => true, 'props' => [ - 'placeholder' => '默认 /api/pay/create', - ], - ], - [ - 'type' => 'input', - 'field' => 'query_path', - 'title' => '查单路径', - 'value' => '/api/pay/query', - 'props' => [ - 'placeholder' => '默认 /api/pay/query', - ], - ], - [ - 'type' => 'input', - 'field' => 'refund_path', - 'title' => '退款路径', - 'value' => '/api/pay/refund', - 'props' => [ - 'placeholder' => '默认 /api/pay/refund', - ], - ], - [ - 'type' => 'input', - 'field' => 'close_path', - 'title' => '关单路径', - 'value' => '/api/pay/close', - 'props' => [ - 'placeholder' => '默认 /api/pay/close', - ], - ], - [ - 'type' => 'textarea', - 'field' => 'type_mapping_json', - 'title' => '支付方式映射', - 'value' => "{\n \"alipay\": \"alipay\",\n \"wxpay\": \"wxpay\",\n \"unionpay\": \"bank\"\n}", - 'props' => [ - 'placeholder' => 'JSON 格式,例如 {\"unionpay\":\"bank\"}', - 'rows' => 6, + 'checkedText' => '支持', + 'uncheckedText' => '不支持', ], ], ], ]; - public function init(array $channelConfig): void - { - parent::init($channelConfig); - } - + /** + * 发起 V2 支付。 + * + * 根据订单里的 `_submit_type` 决定走页面跳转还是 API 创建订单。 + * API 模式会按支付方式配置补充 method、授权码等产品参数。 + * + * @param array $order 标准插件下单参数 + * @return array + */ public function pay(array $order): array { $payload = [ - 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), - 'method' => $this->resolveV2Method($order), - 'type' => $this->resolveUpstreamType($order, [ - 'alipay' => 'alipay', - 'wxpay' => 'wxpay', - 'unionpay' => 'bank', - ]), - 'out_trade_no' => $this->resolveOrderNo($order), - 'notify_url' => trim((string) ($order['callback_url'] ?? '')), - 'name' => $this->resolveSubject($order), - 'money' => $this->amountToMoney($this->resolveAmount($order)), + 'pid' => (string) $this->getConfig('pid'), + 'type' => (string) $order['pay_type_code'], + 'out_trade_no' => (string) $order['pay_no'], + 'notify_url' => (string) $order['callback_url'], + 'name' => mb_strcut((string) $order['subject'], 0, 127, 'UTF-8'), + 'money' => FormatHelper::amount((int) $order['amount']), 'timestamp' => (string) time(), - 'clientip' => trim((string) ($order['client_ip'] ?? '127.0.0.1')), ]; - $returnUrl = trim((string) ($order['return_url'] ?? '')); + $returnUrl = (string) ($order['return_url'] ?? ''); if ($returnUrl !== '') { $payload['return_url'] = $returnUrl; } - $device = $this->resolveDevice($order); - if ($device !== '') { - $payload['device'] = $device; - } - - $param = $this->resolveParamValue($order); + $param = (string) ($order['extra']['merchant']['param'] ?? ''); if ($param !== '') { $payload['param'] = $param; } - $context = $this->resolveExtraContext($order); + if ((string) $order['extra']['_submit_type'] === EpayProtocolConstant::SUBMIT_TYPE_PAGE) { + return $this->submitPay($payload); + } + + return $this->apiPay($payload, $order); + } + + /** + * 使用 V2 页面跳转支付。 + * + * 页面跳转阶段无法拿到上游平台单号,因此先使用本地支付单号占位。 + * + * @param array $payload V2 支付参数 + * @return array + */ + private function submitPay(array $payload): array + { + $payload = $this->signPayload($payload); + $action = $this->gatewayUrl('/api/pay/submit'); + $query = http_build_query($payload, '', '&', PHP_QUERY_RFC3986); + $url = $action . (str_contains($action, '?') ? '&' : '?') . $query; + + return [ + 'pay_page' => 'jump', + 'pay_type' => (string) $payload['type'], + 'pay_product' => 'submit', + 'pay_action' => 'submitPay', + 'pay_params' => [ + 'url' => $url, + 'action' => $action, + 'method' => 'get', + 'payload' => $payload, + ], + // 页面跳转阶段还拿不到上游真实单号,先用本地 out_trade_no/pay_no 占位。 + 'chan_order_no' => (string) $payload['out_trade_no'], + 'chan_trade_no' => '', + ]; + } + + /** + * 使用 V2 API 创建订单。 + * + * API 创建会校验上游签名,并把上游返回的 pay_type 转换成收银台可识别的承接类型。 + * + * @param array $payload V2 支付参数 + * @param array $order 标准插件下单参数 + * @return array + */ + private function apiPay(array $payload, array $order): array + { + if (in_array($this->getConfig('support_api', true), [false, 0, '0'], true)) { + throw new PaymentException('当前通道未开启 V2 API 创建订单', 40200); + } + + $payload['method'] = (string) $order['extra']['payment']['method']; + $payload['clientip'] = (string) $order['client_ip']; + $payload['device'] = (string) $order['_env']; + foreach (['auth_code', 'sub_openid', 'sub_appid'] as $key) { - $value = trim((string) ($context[$key] ?? '')); + $value = (string) ($order['extra']['payment'][$key] ?? ''); if ($value !== '') { $payload[$key] = $value; } } - $payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥')); - $response = $this->isMockEnabled() - ? $this->buildMockPayResponse($payload, $order) - : $this->requestFormJson('POST', $this->resolveGatewayUrl('create_path', '/api/pay/create'), $payload); - $this->verifyPayloadSignature( - $response, - AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - $this->requireConfigValue('platform_public_key', '上游平台公钥'), - '上游 V2 下单响应验签失败' - ); + $response = $this->requestSignedJson('/api/pay/create', $payload, '上游 V2 下单响应验签失败'); if ((int) ($response['code'] ?? -1) !== 0) { throw new PaymentException((string) ($response['msg'] ?? '上游 V2 下单失败'), 40200, [ @@ -197,45 +216,55 @@ class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginIn ]); } - $channelNos = $this->resolveChannelNos($response + [ - 'trade_no' => (string) ($response['trade_no'] ?? $payload['out_trade_no']), - ]); - $payType = strtolower(trim((string) ($response['pay_type'] ?? ''))); - $payParams = $this->normalizePayResponse($payType, $response['pay_info'] ?? null); + $channelOrderNo = (string) ($response['trade_no'] ?? ''); + if ($channelOrderNo === '') { + throw new PaymentException('上游 V2 未返回平台订单号', 40200, [ + 'response' => $response, + ]); + } + + $payAction = strtolower((string) ($response['pay_type'] ?? '')); + if ($payAction === '') { + throw new PaymentException('上游 V2 未返回支付承接类型', 40200, [ + 'response' => $response, + ]); + } return [ - 'pay_product' => (string) $payload['type'], - 'pay_action' => (string) ($payParams['type'] ?? $payType), - 'pay_params' => $payParams, - 'chan_order_no' => $channelNos['channel_order_no'], - 'chan_trade_no' => $channelNos['channel_trade_no'], + 'pay_page' => $this->payPage($payAction), + 'pay_type' => (string) $payload['type'], + 'pay_product' => (string) $payload['method'], + 'pay_action' => $payAction, + 'pay_params' => $this->payParams($payAction, $response), + 'chan_order_no' => $channelOrderNo, + 'chan_trade_no' => (string) ($response['api_trade_no'] ?? ''), ]; } + /** + * 查询 V2 支付订单。 + * + * 优先使用上游 trade_no 查询;页面跳转阶段没有 trade_no 时,退回使用本地 out_trade_no。 + * + * @param array $order 标准插件查单参数 + * @return array + */ public function query(array $order): array { $payload = [ - 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), + 'pid' => (string) $this->getConfig('pid'), 'timestamp' => (string) time(), ]; - $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); - if ($tradeNo !== '') { - $payload['trade_no'] = $tradeNo; + $payNo = (string) ($order['pay_no'] ?? ''); + $channelOrderNo = (string) ($order['chan_order_no'] ?? ''); + if ($channelOrderNo !== '' && $channelOrderNo !== $payNo) { + $payload['trade_no'] = $channelOrderNo; } else { - $payload['out_trade_no'] = $this->resolveOrderNo($order); + $payload['out_trade_no'] = $payNo; } - $payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥')); - $response = $this->isMockEnabled() - ? $this->buildMockQueryResponse($order) - : $this->requestFormJson('POST', $this->resolveGatewayUrl('query_path', '/api/pay/query'), $payload); - $this->verifyPayloadSignature( - $response, - AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - $this->requireConfigValue('platform_public_key', '上游平台公钥'), - '上游 V2 查单响应验签失败' - ); + $response = $this->requestSignedJson('/api/pay/query', $payload, '上游 V2 查单响应验签失败'); if ((int) ($response['code'] ?? -1) !== 0) { return [ @@ -245,51 +274,47 @@ class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginIn ]; } - $channelNos = $this->resolveChannelNos($response); $statusCode = (int) ($response['status'] ?? 0); - $status = match ($statusCode) { - 1, 2 => PaymentPluginStatusConstant::SUCCESS, - default => PaymentPluginStatusConstant::PENDING, - }; + $status = in_array($statusCode, [1, 2, 3], true) + ? PaymentPluginStatusConstant::SUCCESS + : PaymentPluginStatusConstant::PENDING; return [ 'success' => true, 'status' => $status, - 'channel_order_no' => $channelNos['channel_order_no'], - 'channel_trade_no' => $channelNos['channel_trade_no'], + 'channel_order_no' => (string) ($response['trade_no'] ?? $channelOrderNo), + 'channel_trade_no' => (string) ($response['api_trade_no'] ?? ''), 'channel_status' => (string) $statusCode, - 'paid_at' => $response['endtime'] ?? null, - 'ext_json' => [ - 'refundmoney' => (string) ($response['refundmoney'] ?? ''), - 'channel_response' => $response, - ], + 'message' => (string) ($response['msg'] ?? ''), + 'paid_at' => $status === PaymentPluginStatusConstant::SUCCESS ? ($response['endtime'] ?? null) : null, + 'raw_data' => $response, ]; } + /** + * 关闭 V2 支付订单。 + * + * 关单同样按 trade_no 优先、out_trade_no 兜底,保持和查单、退款的订单标识选择一致。 + * + * @param array $order 标准插件关单参数 + * @return array + */ public function close(array $order): array { $payload = [ - 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), + 'pid' => (string) $this->getConfig('pid'), 'timestamp' => (string) time(), ]; - $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); - if ($tradeNo !== '') { - $payload['trade_no'] = $tradeNo; + $payNo = (string) ($order['pay_no'] ?? ''); + $channelOrderNo = (string) ($order['chan_order_no'] ?? ''); + if ($channelOrderNo !== '' && $channelOrderNo !== $payNo) { + $payload['trade_no'] = $channelOrderNo; } else { - $payload['out_trade_no'] = $this->resolveOrderNo($order); + $payload['out_trade_no'] = $payNo; } - $payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥')); - $response = $this->isMockEnabled() - ? $this->buildMockCloseResponse($order) - : $this->requestFormJson('POST', $this->resolveGatewayUrl('close_path', '/api/pay/close'), $payload); - $this->verifyPayloadSignature( - $response, - AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - $this->requireConfigValue('platform_public_key', '上游平台公钥'), - '上游 V2 关单响应验签失败' - ); + $response = $this->requestSignedJson('/api/pay/close', $payload, '上游 V2 关单响应验签失败'); return [ 'success' => (int) ($response['code'] ?? -1) === 0, @@ -298,82 +323,355 @@ class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginIn ]; } + /** + * 提交 V2 订单退款。 + * + * 退款请求需要携带系统退款单号作为 out_refund_no,方便后续对账和幂等追踪。 + * + * @param array $order 标准插件退款参数 + * @return array + */ public function refund(array $order): array { $payload = [ - 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), - 'money' => $this->amountToMoney((int) ($order['refund_amount'] ?? 0)), + 'pid' => (string) $this->getConfig('pid'), + 'money' => FormatHelper::amount((int) $order['refund_amount']), 'timestamp' => (string) time(), ]; - $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); - if ($tradeNo !== '') { - $payload['trade_no'] = $tradeNo; + $payNo = (string) ($order['pay_no'] ?? ''); + $channelOrderNo = (string) ($order['chan_order_no'] ?? ''); + if ($channelOrderNo !== '' && $channelOrderNo !== $payNo) { + $payload['trade_no'] = $channelOrderNo; } else { - $payload['out_trade_no'] = $this->resolveOrderNo($order); + $payload['out_trade_no'] = $payNo; } - $outRefundNo = trim((string) ($order['refund_no'] ?? '')); - if ($outRefundNo !== '') { - $payload['out_refund_no'] = $outRefundNo; + $refundNo = (string) ($order['refund_no'] ?? ''); + if ($refundNo !== '') { + $payload['out_refund_no'] = $refundNo; } - $payload = $this->signPayload($payload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $this->requireConfigValue('merchant_private_key', '上游商户私钥')); - $response = $this->isMockEnabled() - ? $this->buildMockRefundResponse($order) - : $this->requestFormJson('POST', $this->resolveGatewayUrl('refund_path', '/api/pay/refund'), $payload); - $this->verifyPayloadSignature( - $response, - AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - $this->requireConfigValue('platform_public_key', '上游平台公钥'), - '上游 V2 退款响应验签失败' - ); + $response = $this->requestSignedJson('/api/pay/refund', $payload, '上游 V2 退款响应验签失败'); return [ 'success' => (int) ($response['code'] ?? -1) === 0, 'msg' => (string) ($response['msg'] ?? ''), - 'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['out_refund_no'] ?? '')), + 'chan_refund_no' => (string) ($response['refund_no'] ?? ''), 'raw_data' => $response, ]; } + /** + * 解析并验签 V2 支付回调。 + * + * 回调先校验时间戳和 RSA 签名,再把 trade_status 映射为平台内部支付状态。 + * + * @param Request $request 回调请求 + * @return array + */ public function notify(Request $request): array { - $payload = $this->resolveNotifyPayload($request); - $this->verifyPayloadSignature( - $payload, - AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - $this->requireConfigValue('platform_public_key', '上游平台公钥'), - '上游 V2 回调验签失败' - ); + $payload = (array) $request->all(); + $this->verifyPayload($payload, '上游 V2 回调验签失败'); - $channelNos = $this->resolveChannelNos($payload); - $status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? '')); + $channelOrderNo = (string) ($payload['trade_no'] ?? ''); + if ($channelOrderNo === '') { + throw new PaymentException('上游 V2 回调缺少平台订单号', 40200); + } + + $tradeStatus = strtoupper((string) ($payload['trade_status'] ?? '')); + $status = $tradeStatus === NotifyConstant::EPAY_TRADE_STATUS_SUCCESS + ? PaymentPluginStatusConstant::SUCCESS + : PaymentPluginStatusConstant::PENDING; return [ 'status' => $status, - 'message' => (string) ($payload['trade_status'] ?? ''), - 'channel_order_no' => $channelNos['channel_order_no'], - 'channel_trade_no' => $channelNos['channel_trade_no'], - 'channel_status' => (string) ($payload['trade_status'] ?? ''), - 'paid_at' => $payload['endtime'] ?? null, - 'ext_json' => [ - 'channel_type' => (string) ($payload['type'] ?? ''), - 'timestamp' => (string) ($payload['timestamp'] ?? ''), - ], + 'message' => $tradeStatus, + 'channel_order_no' => $channelOrderNo, + 'channel_trade_no' => $channelOrderNo, + 'channel_status' => $tradeStatus, + 'paid_at' => $status === PaymentPluginStatusConstant::SUCCESS ? ($payload['endtime'] ?? null) : null, ]; } + /** + * 返回上游要求的成功应答。 + */ public function notifySuccess(): string|Response { return 'success'; } + /** + * 返回上游要求的失败应答。 + */ public function notifyFail(): string|Response { return 'fail'; } + /** + * 发起 V2 转账。 + * + * 转账属于独立插件能力,由 TransferPluginInterface 调用,不参与支付订单状态流转。 + * + * @param array $order 标准插件转账参数 + * @return array + */ + public function transfer(array $order): array + { + $payload = [ + 'pid' => (string) $this->getConfig('pid'), + 'type' => (string) $order['type'], + 'account' => (string) $order['account'], + 'name' => (string) $order['name'], + 'money' => FormatHelper::amount((int) $order['amount']), + 'out_biz_no' => (string) $order['biz_no'], + 'timestamp' => (string) time(), + ]; + + foreach (['remark', 'bookid'] as $key) { + $value = (string) ($order[$key] ?? ''); + if ($value !== '') { + $payload[$key] = $value; + } + } + + $response = $this->requestSignedJson('/api/transfer/submit', $payload, '上游 V2 转账响应验签失败'); + + return $this->transferResult($response); + } + + /** + * 查询 V2 转账状态。 + * + * 优先使用上游 biz_no 查询;没有上游单号时使用本地 out_biz_no。 + * + * @param array $order 标准插件转账查询参数 + * @return array + */ + public function transferQuery(array $order): array + { + $payload = [ + 'pid' => (string) $this->getConfig('pid'), + 'timestamp' => (string) time(), + ]; + + $channelOrderNo = (string) ($order['channel_order_no'] ?? ''); + if ($channelOrderNo !== '') { + $payload['biz_no'] = $channelOrderNo; + } else { + $payload['out_biz_no'] = (string) $order['biz_no']; + } + + $response = $this->requestSignedJson('/api/transfer/query', $payload, '上游 V2 转账查询响应验签失败'); + + return $this->transferResult($response); + } + + /** + * 查询 V2 转账余额。 + * + * 余额查询只返回上游可用余额与费率信息,不产生本地业务状态变更。 + * + * @param array $order 查询参数 + * @return array + */ + public function transferBalance(array $order): array + { + $payload = [ + 'pid' => (string) $this->getConfig('pid'), + 'timestamp' => (string) time(), + ]; + + $response = $this->requestSignedJson('/api/transfer/balance', $payload, '上游 V2 转账余额响应验签失败'); + + return [ + 'success' => (int) ($response['code'] ?? -1) === 0, + 'available_money' => (string) ($response['available_money'] ?? ''), + 'transfer_rate' => (string) ($response['transfer_rate'] ?? ''), + 'message' => (string) ($response['msg'] ?? ''), + 'raw_data' => $response, + ]; + } + + /** + * 发送签名表单并解码 JSON 响应。 + * + * V2 接口统一走 POST 表单:请求先用商户私钥签名,响应再用平台公钥验签。 + * + * @param string $path 接口路径 + * @param array $payload 请求参数 + * @param string $verifyMessage 验签失败消息 + * @return array + */ + private function requestSignedJson(string $path, array $payload, string $verifyMessage): array + { + $response = $this->request('POST', $this->gatewayUrl($path), [ + 'form_params' => $this->signPayload($payload), + 'headers' => ['Accept' => 'application/json'], + ]); + + $body = (string) $response->getBody(); + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new PaymentException('上游网关响应不是合法 JSON', 40200, [ + 'body_excerpt' => mb_strcut(preg_replace('/\s+/', ' ', $body) ?? $body, 0, 240, 'UTF-8'), + ]); + } + + $this->verifyPayload($decoded, $verifyMessage); + + return $decoded; + } + + /** + * 使用商户私钥给请求参数签名。 + * + * @param array $payload 待签名参数 + * @return array + */ + private function signPayload(array $payload): array + { + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_RSA; + $payload['sign'] = $this->signerManager()->sign( + $payload, + AuthConstant::API_SIGN_NAME_RSA, + (string) $this->getConfig('merchant_private_key') + ); + + return $payload; + } + + /** + * 校验上游响应或回调签名。 + * + * 同时限制时间戳偏差,避免旧通知被重复投递后绕过业务幂等。 + * + * @param array $payload 待验签参数 + * @param string $message 验签失败提示 + * @return void + */ + private function verifyPayload(array $payload, string $message): void + { + $timestamp = (int) ($payload['timestamp'] ?? 0); + if ($timestamp <= 0 || abs(time() - $timestamp) > 300) { + throw new PaymentException($message, 40200); + } + + $sign = (string) ($payload['sign'] ?? ''); + if ($sign === '' || !$this->signerManager()->verify( + $payload, + (string) ($payload['sign_type'] ?? AuthConstant::API_SIGN_NAME_RSA), + $sign, + (string) $this->getConfig('platform_public_key') + )) { + throw new PaymentException($message, 40200); + } + } + + /** + * 将上游 pay_type 映射为前端收银台承接页类型。 + */ + private function payPage(string $payType): string + { + return match ($payType) { + 'jump' => 'jump', + 'html' => 'html', + 'qrcode' => 'qrcode', + 'urlscheme' => 'urlscheme', + 'jsapi' => 'jsapi', + default => 'page', + }; + } + + /** + * 按承接页组件固定字段包装 V2 原始支付参数。 + * + * @param string $payType 上游返回的支付内容类型 + * @param array $response 上游原始响应 + * @return array + */ + private function payParams(string $payType, array $response): array + { + $payInfo = $response['pay_info'] ?? ''; + + return match ($payType) { + 'qrcode' => [ + 'qrcode' => (string) $payInfo, + 'raw' => $response, + ], + 'html' => [ + 'html' => (string) $payInfo, + 'raw' => $response, + ], + 'jump' => [ + 'url' => (string) $payInfo, + 'raw' => $response, + ], + 'urlscheme' => [ + 'urlscheme' => (string) $payInfo, + 'raw' => $response, + ], + 'jsapi' => $this->jsapiPayParams($payInfo, $response), + default => [ + '_page' => $payType, + 'params' => $payInfo, + 'raw' => $response, + ], + }; + } + + /** + * 兼容 V2 JSAPI 可能返回数组或 JSON 字符串两种形态。 + * + * @param mixed $payInfo V2 pay_info + * @param array $response 上游原始响应 + * @return array + */ + private function jsapiPayParams(mixed $payInfo, array $response): array + { + if (is_array($payInfo)) { + $params = $payInfo; + } else { + $decoded = json_decode((string) $payInfo, true); + $params = is_array($decoded) ? $decoded : ['params' => (string) $payInfo]; + } + + $params['raw'] = $response; + + return $params; + } + + /** + * 归一化 V2 转账响应。 + * + * @param array $response 上游原始响应 + * @return array + */ + private function transferResult(array $response): array + { + $statusCode = (int) ($response['status'] ?? 0); + $status = match ($statusCode) { + 1 => PaymentPluginStatusConstant::SUCCESS, + 2 => PaymentPluginStatusConstant::FAILED, + default => PaymentPluginStatusConstant::PENDING, + }; + + return [ + 'success' => (int) ($response['code'] ?? -1) === 0, + 'status' => $status, + 'status_code' => $statusCode, + 'msg' => (string) ($response['errmsg'] ?? $response['msg'] ?? ''), + 'channel_order_no' => (string) ($response['biz_no'] ?? $response['orderid'] ?? ''), + 'channel_trade_no' => (string) ($response['orderid'] ?? $response['biz_no'] ?? ''), + 'orderid' => (string) ($response['orderid'] ?? ''), + 'succeeded_at' => $status === PaymentPluginStatusConstant::SUCCESS ? ($response['paydate'] ?? null) : null, + 'raw_data' => $response, + ]; + } + /** * 获取签名管理器。 */ @@ -381,7 +679,7 @@ class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginIn { if ($this->epaySignerManager === null) { /** @var EpaySignerManager $manager */ - $manager = container_make(EpaySignerManager::class, []); + $manager = container_get(EpaySignerManager::class); $this->epaySignerManager = $manager; } @@ -389,573 +687,12 @@ class EpayV2Payment extends BasePayment implements PaymentInterface, PayPluginIn } /** - * 是否启用插件内置 mock。 - */ - private function isMockEnabled(): bool - { - $value = $this->getConfig('mock_enabled', false); - if (is_bool($value)) { - return $value; - } - - if (is_numeric($value)) { - return (int) $value === 1; - } - - $filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); - return $filtered ?? false; - } - - /** - * 读取必填配置。 - */ - private function requireConfigValue(string $key, string $label): string - { - $value = trim((string) $this->getConfig($key, '')); - if ($value === '') { - throw new PaymentException($label . '未配置', 40200, [ - 'config_key' => $key, - ]); - } - - return $value; - } - - /** - * 构建上游接口地址。 - */ - private function resolveGatewayUrl(string $pathConfigKey, string $defaultPath): string - { - $baseUrl = rtrim($this->requireConfigValue('gateway_url', '上游网关地址'), '/'); - $path = trim((string) $this->getConfig($pathConfigKey, $defaultPath)); - if ($path === '') { - $path = $defaultPath; - } - - if (preg_match('/^https?:\/\//i', $path) === 1) { - return rtrim($path, '/'); - } - - return $baseUrl . '/' . ltrim($path, '/'); - } - - /** - * @param array $payload - * @return array - */ - private function requestFormJson(string $method, string $url, array $payload): array - { - $response = $this->request($method, $url, [ - 'form_params' => $payload, - 'headers' => [ - 'Accept' => 'application/json', - ], - ]); - - return $this->decodeJsonResponse((string) $response->getBody(), $url); - } - - /** - * @return array - */ - private function decodeJsonResponse(string $body, string $url): array - { - $decoded = json_decode($body, true); - if (!is_array($decoded)) { - throw new PaymentException('上游网关响应不是合法 JSON', 40200, [ - 'url' => $url, - 'body_excerpt' => $this->clipText($body), - ]); - } - - return $decoded; - } - - /** - * @param array $payload - * @return array - */ - private function signPayload(array $payload, string $signType, string $key): array - { - $payload['sign_type'] = $signType; - $payload['sign'] = $this->signerManager()->sign($payload, $signType, $key); - - return $payload; - } - - /** - * @param array $payload - */ - private function verifyPayloadSignature(array $payload, string $defaultSignType, string $key, string $message): void - { - $sign = trim((string) ($payload['sign'] ?? '')); - if ($sign === '') { - throw new PaymentException($message, 40200, ['reason' => 'missing_sign']); - } - - $signType = trim((string) ($payload['sign_type'] ?? $defaultSignType)); - if (!$this->signerManager()->verify($payload, $signType, $sign, $key)) { - throw new PaymentException($message, 40200, [ - 'sign_type' => $signType, - ]); - } - } - - /** - * @param array $defaultMapping - * @return array - */ - private function resolveTypeMapping(array $defaultMapping): array - { - $raw = $this->getConfig('type_mapping_json', ''); - $mapping = $defaultMapping; - - if (is_array($raw)) { - foreach ($raw as $key => $value) { - $source = strtolower(trim((string) $key)); - $target = strtolower(trim((string) $value)); - if ($source !== '' && $target !== '') { - $mapping[$source] = $target; - } - } - - return $mapping; - } - - $text = trim((string) $raw); - if ($text === '') { - return $mapping; - } - - $decoded = json_decode($text, true); - if (!is_array($decoded)) { - throw new PaymentException('支付方式映射配置不是合法 JSON', 40200, [ - 'config_key' => 'type_mapping_json', - ]); - } - - foreach ($decoded as $key => $value) { - $source = strtolower(trim((string) $key)); - $target = strtolower(trim((string) $value)); - if ($source !== '' && $target !== '') { - $mapping[$source] = $target; - } - } - - return $mapping; - } - - /** - * @param array $defaultMapping - */ - private function resolveUpstreamType(array $order, array $defaultMapping): string - { - $payTypeCode = strtolower(trim((string) ($order['pay_type_code'] ?? ''))); - if ($payTypeCode === '') { - throw new PaymentException('订单缺少支付方式编码', 40200); - } - - $mapping = $this->resolveTypeMapping($defaultMapping); - $upstreamType = strtolower(trim((string) ($mapping[$payTypeCode] ?? ''))); - if ($upstreamType === '') { - throw new PaymentException('未配置上游支付方式映射', 40200, [ - 'pay_type_code' => $payTypeCode, - ]); - } - - return $upstreamType; - } - - /** - * 获取平台内部支付单号,作为上游商户订单号。 - */ - private function resolveOrderNo(array $order): string - { - $orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? '')); - if ($orderNo === '') { - throw new PaymentException('订单缺少订单号', 40200); - } - - return $orderNo; - } - - /** - * 获取支付金额(分)。 - */ - private function resolveAmount(array $order): int - { - $amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? 0); - if ($amount <= 0) { - throw new PaymentException('订单金额不合法', 40200); - } - - return $amount; - } - - /** - * 获取订单标题。 - */ - private function resolveSubject(array $order): string - { - $subject = trim((string) ($order['subject'] ?? $order['body'] ?? '')); - if ($subject === '') { - throw new PaymentException('订单标题不能为空', 40200); - } - - if (function_exists('mb_strcut')) { - return mb_strcut($subject, 0, 127, 'UTF-8'); - } - - return substr($subject, 0, 127); - } - - /** - * @return array - */ - private function resolveExtraContext(array $order): array - { - $context = []; - foreach ([$order['extra'] ?? null, $order['param'] ?? null] as $bag) { - if (is_array($bag)) { - $context = array_merge($context, $bag); - foreach (['merchant', 'payment', 'source'] as $section) { - if (isset($bag[$section]) && is_array($bag[$section])) { - $context = array_merge($context, $bag[$section]); - } - } - continue; - } - - if (!is_string($bag)) { - continue; - } - - $text = trim($bag); - if ($text === '') { - continue; - } - - $decoded = json_decode($text, true); - if (is_array($decoded)) { - $context = array_merge($context, $decoded); - continue; - } - - parse_str($text, $parsed); - if (is_array($parsed) && $parsed !== []) { - $context = array_merge($context, $parsed); - } - } - - return $context; - } - - /** - * 归一化备注透传字段。 - */ - private function resolveParamValue(array $order): string - { - $context = $this->resolveExtraContext($order); - $param = $context['param'] ?? null; - if ($param === null || $param === '') { - return ''; - } - - if (is_scalar($param)) { - return trim((string) $param); - } - - $json = json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - return $json !== false ? $json : ''; - } - - /** - * 归一化客户端环境。 - */ - private function resolveDevice(array $order, string $default = 'pc'): string - { - $device = strtolower(trim((string) ($order['_env'] ?? $order['device'] ?? ''))); - return $device !== '' ? $device : $default; - } - - /** - * 解析 V2 上游 method。 - */ - private function resolveV2Method(array $order): string - { - $context = $this->resolveExtraContext($order); - $method = strtolower(trim((string) ($context['method'] ?? ''))); - $allowed = ['web', 'jump', 'jsapi', 'app', 'scan', 'applet']; - if (in_array($method, $allowed, true)) { - return $method; - } - - if (trim((string) ($context['auth_code'] ?? '')) !== '') { - return 'scan'; - } - - if (trim((string) ($context['sub_openid'] ?? '')) !== '') { - return 'jsapi'; - } - - return match ($this->resolveDevice($order)) { - 'wechat' => 'jsapi', - 'mobile', 'qq', 'alipay' => 'jump', - default => 'web', - }; - } - - /** - * 提取回调入参。 + * 拼接上游网关地址。 * - * @return array + * 后台配置只保存根地址,具体协议路径由插件内部统一补齐。 */ - private function resolveNotifyPayload(Request $request): array + private function gatewayUrl(string $path): string { - $payload = array_merge((array) $request->get(), (array) $request->post()); - if ($payload !== []) { - return $payload; - } - - $all = $request->all(); - - return is_array($all) ? $all : []; - } - - /** - * 将分转换为元字符串。 - */ - private function amountToMoney(int $amount): string - { - return FormatHelper::amount($amount); - } - - /** - * @return array{channel_order_no: string, channel_trade_no: string} - */ - private function resolveChannelNos(array $payload): array - { - $channelOrderNo = trim((string) ($payload['trade_no'] ?? $payload['transaction_id'] ?? '')); - $channelTradeNo = trim((string) ($payload['api_trade_no'] ?? $payload['channel_trade_no'] ?? '')); - - if ($channelOrderNo === '' && $channelTradeNo === '') { - throw new PaymentException('上游返回缺少渠道订单号', 40200); - } - - if ($channelOrderNo === '') { - $channelOrderNo = $channelTradeNo; - } - if ($channelTradeNo === '') { - $channelTradeNo = $channelOrderNo; - } - - return [ - 'channel_order_no' => $channelOrderNo, - 'channel_trade_no' => $channelTradeNo, - ]; - } - - /** - * 归一化回调支付状态。 - */ - private function normalizeNotifyStatus(string $tradeStatus): string - { - $tradeStatus = strtoupper(trim($tradeStatus)); - if (in_array($tradeStatus, ['TRADE_SUCCESS', 'SUCCESS', 'PAY_SUCCESS', 'FINISHED'], true)) { - return PaymentPluginStatusConstant::SUCCESS; - } - - if (in_array($tradeStatus, ['TRADE_FAIL', 'FAILED', 'TRADE_CLOSED', 'CLOSED', 'PAYERROR'], true)) { - return PaymentPluginStatusConstant::FAILED; - } - - return PaymentPluginStatusConstant::PENDING; - } - - /** - * 生成响应文本摘要。 - */ - private function clipText(string $text, int $length = 240): string - { - $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text); - if ($text === '') { - return ''; - } - - return strlen($text) <= $length ? $text : substr($text, 0, $length) . '...'; - } - - /** - * @param array $payload - * @param array $order - * @return array - */ - private function buildMockPayResponse(array $payload, array $order): array - { - $seed = strtolower((string) ($payload['out_trade_no'] ?? $this->resolveOrderNo($order))); - $channelOrderNo = 'V2ORD' . strtoupper(substr(md5($seed), 0, 16)); - $channelTradeNo = 'V2TRD' . strtoupper(substr(sha1($seed), 0, 16)); - - return $this->buildMockSignedResponse([ - 'code' => 0, - 'msg' => 'success', - 'trade_no' => $channelOrderNo, - 'api_trade_no' => $channelTradeNo, - 'pay_type' => 'jump', - 'pay_info' => [ - 'type' => 'jump', - 'payurl' => $this->resolveMockJumpUrl($channelTradeNo), - ], - ]); - } - - /** - * @param array $order - * @return array - */ - private function buildMockQueryResponse(array $order): array - { - $channelOrderNo = trim((string) ($order['chan_order_no'] ?? '')); - $channelTradeNo = trim((string) ($order['chan_trade_no'] ?? '')); - if ($channelOrderNo === '' && $channelTradeNo === '') { - $seed = strtolower($this->resolveOrderNo($order)); - $channelOrderNo = 'V2ORD' . strtoupper(substr(md5($seed), 0, 16)); - $channelTradeNo = 'V2TRD' . strtoupper(substr(sha1($seed), 0, 16)); - } elseif ($channelOrderNo === '') { - $channelOrderNo = $channelTradeNo; - } elseif ($channelTradeNo === '') { - $channelTradeNo = $channelOrderNo; - } - - return $this->buildMockSignedResponse([ - 'code' => 0, - 'msg' => 'success', - 'trade_no' => $channelOrderNo, - 'api_trade_no' => $channelTradeNo, - 'out_trade_no' => $this->resolveOrderNo($order), - 'status' => 1, - 'buyer' => 'MOCK_V2_BUYER', - 'param' => $this->resolveParamValue($order), - 'refundmoney' => '0.00', - 'endtime' => date('Y-m-d H:i:s'), - ]); - } - - /** - * @param array $order - * @return array - */ - private function buildMockCloseResponse(array $order): array - { - return $this->buildMockSignedResponse([ - 'code' => 0, - 'msg' => 'success', - 'trade_no' => trim((string) ($order['chan_order_no'] ?? '')), - 'api_trade_no' => trim((string) ($order['chan_trade_no'] ?? '')), - ]); - } - - /** - * @param array $order - * @return array - */ - private function buildMockRefundResponse(array $order): array - { - $seed = strtolower(trim((string) ($order['refund_no'] ?? $this->resolveOrderNo($order)))); - - return $this->buildMockSignedResponse([ - 'code' => 0, - 'msg' => 'success', - 'refund_no' => 'V2REF' . strtoupper(substr(md5($seed), 0, 16)), - 'out_refund_no' => trim((string) ($order['refund_no'] ?? '')), - 'trade_no' => trim((string) ($order['chan_order_no'] ?? '')), - ]); - } - - /** - * @param array $payload - * @return array - */ - private function buildMockSignedResponse(array $payload): array - { - $payload['timestamp'] = (string) ($payload['timestamp'] ?? time()); - $payload['sign_type'] = AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA; - - $signPayload = $payload; - unset($signPayload['sign'], $signPayload['sign_type']); - $payload['sign'] = $this->signerManager()->sign( - $signPayload, - AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - $this->requireConfigValue('mock_platform_private_key', 'Mock 上游平台私钥') - ); - - return $payload; - } - - /** - * 构建 mock 跳转地址。 - */ - private function resolveMockJumpUrl(string $channelTradeNo): string - { - $baseUrl = trim((string) $this->getConfig('mock_jump_base_url', 'https://mock.epay.test/pay/v2')); - if ($baseUrl === '') { - $baseUrl = 'https://mock.epay.test/pay/v2'; - } - - return rtrim($baseUrl, '/') . '?trade_no=' . rawurlencode($channelTradeNo); - } - - /** - * @return array - */ - private function normalizePayResponse(string $payType, mixed $payInfo): array - { - $payType = strtolower(trim($payType)); - $payload = is_array($payInfo) ? $payInfo : []; - if (!is_array($payload)) { - $payload = []; - } - - if (!is_array($payInfo)) { - $text = trim((string) $payInfo); - if ($text !== '') { - $payload = match ($payType) { - 'jump' => ['payurl' => $text, 'redirect_url' => $text], - 'html' => ['html' => $text], - 'qrcode' => ['qrcode' => $text, 'qrcode_text' => $text], - 'urlscheme' => ['urlscheme' => $text, 'redirect_url' => $text], - default => ['payload' => $text], - }; - } - } - - $payload['type'] = $payType !== '' ? $payType : (string) ($payload['type'] ?? ''); - - if ($payload['type'] === 'jump') { - $jumpUrl = trim((string) ($payload['payurl'] ?? $payload['redirect_url'] ?? $payload['url'] ?? '')); - if ($jumpUrl !== '') { - $payload['payurl'] = $jumpUrl; - $payload['redirect_url'] = $jumpUrl; - } - } - - if ($payload['type'] === 'qrcode') { - $qrcode = trim((string) ($payload['qrcode'] ?? $payload['qrcode_text'] ?? $payload['qrcode_url'] ?? '')); - if ($qrcode !== '') { - $payload['qrcode'] = $qrcode; - $payload['qrcode_text'] = $qrcode; - } - } - - if ($payload['type'] === 'urlscheme') { - $urlscheme = trim((string) ($payload['urlscheme'] ?? $payload['redirect_url'] ?? '')); - if ($urlscheme !== '') { - $payload['urlscheme'] = $urlscheme; - $payload['redirect_url'] = $urlscheme; - } - } - - return $payload; + return rtrim((string) $this->getConfig('api_url'), '/') . '/' . ltrim($path, '/'); } } diff --git a/app/common/payment/PostarReceiptPayment.php b/app/common/payment/PostarReceiptPayment.php new file mode 100644 index 0000000..9eb2804 --- /dev/null +++ b/app/common/payment/PostarReceiptPayment.php @@ -0,0 +1,1070 @@ + + */ + protected array $paymentInfo = [ + 'code' => 'postar_receipt', + 'name' => '星驿付收款单收款', + 'author' => 'MPAY', + 'version' => '1.0.0', + 'pay_types' => ['alipay', 'wxpay', 'unionpay'], + 'transfer_types' => [], + 'config_schema' => [ + [ + 'type' => 'radio', + 'field' => 'receipt_match_mode', + 'title' => '订单匹配模式', + 'value' => 'amount', + 'options' => [ + ['label' => '金额变动', 'value' => 'amount'], + ['label' => '付款备注', 'value' => 'remark'], + ], + 'validate' => [ + ['required' => true, 'message' => '订单匹配模式不能为空'], + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'receipt_valid_seconds', + 'title' => '识别有效期(秒)', + 'value' => 300, + 'props' => [ + 'min' => 60, + 'max' => 1800, + 'step' => 60, + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'amount_offset_max', + 'title' => '最大金额偏移(分)', + 'value' => 99, + 'props' => [ + 'min' => 0, + 'max' => 99, + 'step' => 1, + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'receipt_watcher_query_interval_seconds', + 'title' => '账号查询间隔(秒)', + 'value' => 3, + 'props' => [ + 'min' => 2, + 'max' => 60, + 'step' => 1, + ], + ], + [ + 'type' => 'input', + 'field' => 'watcher_username', + 'title' => '平台登录账号', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入星驿付商户平台登录账号', + ], + 'validate' => [ + ['required' => true, 'message' => '平台登录账号不能为空'], + ], + ], + [ + 'type' => 'password', + 'field' => 'watcher_password', + 'title' => '平台登录密码', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入星驿付商户平台登录密码', + ], + 'validate' => [ + ['required' => true, 'message' => '平台登录密码不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'receipt_account_no', + 'title' => '收款账号标识', + 'value' => '', + 'props' => [ + 'placeholder' => '用于 receipt_watcher 区分账号,可填收款单名称或登录账号', + ], + ], + [ + 'type' => 'input', + 'field' => 'postar_ren_page_id', + 'title' => '收款单ID', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入星驿付收款单 renPageId,例如 209571', + ], + 'validate' => [ + ['required' => true, 'message' => '收款单ID不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'postar_dept_id', + 'title' => '部门ID', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入星驿付收款单 deptId,例如 250399', + ], + 'validate' => [ + ['required' => true, 'message' => '部门ID不能为空'], + ], + ], + [ + 'type' => 'textarea', + 'field' => 'receipt_qrcode_content', + 'title' => '收款码内容', + 'value' => '', + 'props' => [ + 'placeholder' => '可填写二维码解析后的内容,优先用于收银台展示', + 'rows' => 4, + ], + ], + [ + 'type' => 'input', + 'field' => 'receipt_qrcode_image', + 'title' => '收款码图片', + 'value' => '', + 'props' => [ + 'placeholder' => '二维码图片 URL,未配置二维码内容时使用', + ], + ], + ], + ]; + + /** + * 发起星驿付收款单收款。 + * + * 这里不会调用上游 API,只做三件事: + * 1. 根据配置选择金额变动或付款备注模式。 + * 2. 写入本次识别所需的订单元数据和过期时间。 + * 3. 返回收银台 `receiptQrcode` 页面需要的二维码、金额、备注码和倒计时参数。 + * + * @param array $order 标准插件下单参数 + * @return array + */ + public function pay(array $order): array + { + $payNo = (string) $order['pay_no']; + $mode = $this->receiptMatchMode(); + $prepared = $mode === 'remark' + ? $this->prepareRemarkReceipt($payNo) + : $this->prepareAmountReceipt($payNo); + + $qrcode = trim((string) $this->getConfig('receipt_qrcode_content', '')); + $image = trim((string) $this->getConfig('receipt_qrcode_image', '')); + if ($qrcode === '' && $image === '') { + throw new PaymentException('星驿付收款单插件未配置收款码', 40200); + } + + $params = [ + '_page' => 'receiptQrcode', + 'amount' => FormatHelper::amount((int) $prepared['pay_amount']), + 'original_amount' => FormatHelper::amount((int) $prepared['original_amount']), + 'receipt_match_mode' => $mode, + 'receipt_valid_seconds' => $this->receiptValidSeconds(), + 'expire_at' => (string) $prepared['expire_at'], + 'expire_at_timestamp' => (int) strtotime((string) $prepared['expire_at']), + 'description' => $mode === 'remark' + ? '请扫码付款,并在付款备注中填写识别码。' + : '请扫码付款,并按页面金额完成付款。', + ]; + if ($mode === 'remark') { + $params['remark_code'] = (string) $prepared['remark_code']; + $params['tips'] = '付款备注:' . (string) $prepared['remark_code']; + } + + if ($qrcode !== '') { + $params['qrcode'] = $qrcode; + } + if ($image !== '') { + $params['qrcode_image'] = $image; + } + + return $this->payResult($params, $payNo, (string) ($order['pay_type_code'] ?? '')); + } + + /** + * 主动查单由 receipt_watcher 完成,这里保持支付中。 + * + * 支付运行时的主动查单会调用插件 query(),但星驿付收款单没有标准上游查单接口。 + * 真正的流水查询由 Python receipt_watcher 负责,本方法只返回 pending,避免误推进状态。 + * + * @param array $order 订单参数 + * @return array + */ + public function query(array $order): array + { + return [ + 'success' => true, + 'status' => PaymentPluginStatusConstant::PENDING, + 'channel_order_no' => (string) ($order['channel_order_no'] ?? $order['pay_no'] ?? ''), + 'channel_trade_no' => (string) ($order['channel_trade_no'] ?? $order['pay_no'] ?? ''), + 'message' => '等待 receipt_watcher 查询星驿付流水', + ]; + } + + /** + * 星驿付收款单无上游关单接口。 + * + * @param array $order 订单参数 + * @return array + */ + public function close(array $order): array + { + return [ + 'success' => true, + 'msg' => '星驿付收款单收款无需上游关单', + ]; + } + + /** + * 星驿付收款单不支持接口退款。 + * + * @param array $order 订单参数 + * @return array + */ + public function refund(array $order): array + { + throw new PaymentException('星驿付收款单收款不支持接口退款', 40200); + } + + /** + * HTTP 手工通知入口。 + * + * 该插件正式链路是 Redis 队列数组载荷。这里保留 HTTP notify() 是为了人工重放 + * 或调试同一份归一化流水,内部直接复用 notifyPayload(),不再单独维护两套解析逻辑。 + * + * @param Request $request 请求对象 + * @return array + */ + public function notify(Request $request): array + { + return $this->notifyPayload((array) $request->all()); + } + + /** + * 根据归一化流水定位支付单。 + * + * ChannelNotifyPayloadInterface 的第一阶段:只确认这条流水对应哪个 pay_no。 + * 不在这里推进订单状态,也不写回调日志,后续由服务层再调用 notifyPayload()。 + * + * @param array $payload 通知载荷 + * @return array{pay_no:string} + */ + public function channelNotifyPayload(array $payload): array + { + return ['pay_no' => $this->locatePayNo($payload)]; + } + + /** + * 解析归一化流水为标准支付成功通知。 + * + * ChannelNotifyPayloadInterface 的第二阶段:生成标准插件通知结果。 + * 服务层会校验返回结构,并复用统一的订单状态推进、回调日志和商户通知链路。 + * + * 注意:金额变动模式下,支付单 pay_amount 在这里恢复为原始金额;流水中的实际付款金额 + * 仅写入 ext_json.personal_receipt.notified_amount 供排查。 + * + * @param array $payload 通知载荷 + * @return array + */ + public function notifyPayload(array $payload): array + { + $record = $this->recordPayload($payload); + $payNo = $this->locatePayNo($payload); + $tradeNo = $this->channelTradeNo($record); + $notifiedAmount = isset($record['price']) ? $this->moneyToCents((string) $record['price']) : null; + + $this->restoreOriginalPayAmount($payNo, $record, $tradeNo, $notifiedAmount); + + return [ + 'status' => PaymentPluginStatusConstant::SUCCESS, + 'pay_no' => $payNo, + 'message' => 'receipt_watcher 已确认星驿付收款流水', + 'channel_order_no' => $tradeNo, + 'channel_trade_no' => $tradeNo, + 'channel_status' => 'receipt_watcher_received', + 'paid_at' => $this->paidAtFromRecord($record), + ]; + } + + public function notifySuccess(): string|Response + { + return 'success'; + } + + public function notifyFail(): string|Response + { + return 'fail'; + } + + /** + * 组装收银台承接返回。 + * + * pay_product/pay_action 用于前端和日志识别这是星驿付收款单网页流水监听场景; + * chan_order_no 暂用系统支付单号,因为平台真实流水号要等 receipt_watcher 查询后才知道。 + * + * @param array $params 承接参数 + * @param string $payNo 支付单号 + * @param string $payType 支付方式 + * @return array + */ + private function payResult(array $params, string $payNo, string $payType): array + { + $payType = trim($payType) !== '' ? trim($payType) : 'alipay'; + + return [ + 'pay_page' => 'page', + 'pay_type' => $payType, + 'pay_product' => 'receipt_plate', + 'pay_action' => 'web_watcher', + 'pay_params' => $params, + 'chan_order_no' => $payNo, + 'chan_trade_no' => '', + ]; + } + + /** + * 读取订单匹配模式。 + * + * 只允许 `amount` 和 `remark` 两种模式,非法配置值按 `amount` 处理。 + * + * @return string 匹配模式 + */ + private function receiptMatchMode(): string + { + return (string) $this->getConfig('receipt_match_mode', 'amount') === 'remark' ? 'remark' : 'amount'; + } + + /** + * 读取收款识别有效期。 + * + * 有效期用于订单过期时间、金额占用窗口、备注码缓存时间。 + * + * @return int 有效期秒数 + */ + private function receiptValidSeconds(): int + { + return max(60, (int) $this->getConfig('receipt_valid_seconds', 300)); + } + + /** + * 读取最大金额偏移。 + * + * 单位是分。默认最多 +0.99,超过后直接失败,不继续扩大金额范围。 + * + * @return int 最大金额偏移,单位分 + */ + private function amountOffsetMax(): int + { + return min(99, max(0, (int) $this->getConfig('amount_offset_max', 99))); + } + + /** + * 准备金额变动收款参数。 + * + * 同一收款账号内,查找有效期内已经占用的金额,从原始金额开始按 0.01 递增寻找 + * 最小可用金额。例如 10.01 已过期时,新的订单可以重新使用 10.01。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareAmountReceipt(string $payNo): array + { + return $this->withAccountLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $usedAmounts = $this->payOrderRepository->listUsedReceiptAmounts( + $this->receiptChannelIds(), + $payNo, + date('Y-m-d H:i:s') + ); + + $used = array_fill_keys($usedAmounts, true); + $receiptAmount = 0; + for ($offset = 0; $offset <= $this->amountOffsetMax(); $offset++) { + $candidate = $originalAmount + $offset; + if (!isset($used[$candidate])) { + $receiptAmount = $candidate; + break; + } + } + + if ($receiptAmount <= 0) { + throw new PaymentException('当前账号可用金额偏移已用尽', 40200, [ + 'pay_no' => $payNo, + 'api_config_id' => (int) $this->getConfig('api_config_id'), + ]); + } + + $this->persistReceiptMeta($payOrder, [ + 'platform' => 'postar_receipt', + 'mode' => 'amount', + 'original_amount' => $originalAmount, + 'receipt_amount' => $receiptAmount, + 'offset_amount' => $receiptAmount - $originalAmount, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $receiptAmount, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 准备备注收款参数。 + * + * 备注模式不改变实际付款金额,只为本次订单生成 4 位识别码。 + * receipt_watcher 查询到流水后,插件会从 remark 字段中提取该识别码定位订单。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareRemarkReceipt(string $payNo): array + { + return $this->withAccountLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $remarkCode = $this->allocateRemarkCode($payNo); + + $this->persistReceiptMeta($payOrder, [ + 'platform' => 'postar_receipt', + 'mode' => 'remark', + 'original_amount' => $originalAmount, + 'receipt_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 加锁读取支付单。 + * + * pay() 准备识别信息和 notifyPayload() 恢复原始金额都需要在事务内锁定同一支付单, + * 避免并发通知或重复发起导致金额和扩展信息交叉覆盖。 + * + * @param string $payNo 支付单号 + * @return PayOrder + */ + private function lockedPayOrder(string $payNo): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new PaymentException('支付单不存在', 40402, ['pay_no' => $payNo]); + } + + return $payOrder; + } + + /** + * 写入收款识别元数据。 + * + * personal_receipt 是该类插件的专用扩展分区: + * - original_amount 保存业务原始金额。 + * - receipt_amount 保存当次页面提示付款金额。 + * - offset_amount 保存金额偏移。 + * - remark_code 保存备注识别码。 + * - expire_at 保存本次识别有效期。 + * + * @param PayOrder $payOrder 支付单 + * @param array $meta 元数据 + * @return void + */ + private function persistReceiptMeta(PayOrder $payOrder, array $meta): void + { + $payOrder->pay_amount = (int) $meta['receipt_amount']; + $payOrder->expire_at = (string) $meta['expire_at']; + $extJson = (array) ($payOrder->ext_json ?? []); + $extJson['personal_receipt'] = $meta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + } + + /** + * 读取原始订单金额。 + * + * 支付单可能已被金额变动模式临时改成识别金额,因此优先从 + * ext_json.personal_receipt.original_amount 取业务原始金额。 + * + * @param PayOrder $payOrder 支付单 + * @return int 原始订单金额,单位分 + */ + private function originalAmount(PayOrder $payOrder): int + { + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + return $originalAmount > 0 ? $originalAmount : (int) $payOrder->pay_amount; + } + + /** + * 通知识别完成后恢复支付单金额。 + * + * 这是金额变动模式的关键收口:流水确认后先把 pay_amount 恢复为 original_amount, + * 再返回标准成功结果,确保后续账户入账、清算、统计都按业务订单原始金额计算。 + * + * @param string $payNo 支付单号 + * @param array $record 流水记录 + * @param string $tradeNo 第三方流水号 + * @param int|null $notifiedAmount 实际付款金额 + * @return void + */ + private function restoreOriginalPayAmount(string $payNo, array $record, string $tradeNo, ?int $notifiedAmount): void + { + Db::transaction(function () use ($payNo, $record, $tradeNo, $notifiedAmount): void { + $payOrder = $this->lockedPayOrder($payNo); + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + if ($originalAmount > 0) { + $payOrder->pay_amount = $originalAmount; + } + + $receiptMeta['notified_at'] = $this->paidAtFromRecord($record) ?? date('Y-m-d H:i:s'); + $receiptMeta['channel_trade_no'] = $tradeNo; + $receiptMeta['record'] = $record; + if ($notifiedAmount !== null) { + $receiptMeta['notified_amount'] = $notifiedAmount; + } + $extJson['personal_receipt'] = $receiptMeta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + }); + } + + /** + * 定位支付单号。 + * + * 匹配顺序: + * 1. 如果流水带平台订单号,优先按 channel_order_no/channel_trade_no 匹配。 + * 2. 否则按当前插件配置走付款备注或金额变动模式。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNo(array $payload): string + { + $record = $this->recordPayload($payload); + $directPayNo = $this->locatePayNoByChannelOrder($record); + if ($directPayNo !== '') { + return $directPayNo; + } + + return $this->receiptMatchMode() === 'remark' + ? $this->locatePayNoByRemark($record) + : $this->locatePayNoByAmount($record); + } + + /** + * 通过平台流水号定位支付单。 + * + * 已经成功处理过的流水会把平台订单号回填到 channel_order_no/channel_trade_no, + * 后续重复流水可以优先命中这里,减少金额或备注重复判断的不确定性。 + * + * @param array $record 流水记录 + * @return string 支付单号 + */ + private function locatePayNoByChannelOrder(array $record): string + { + $orderNo = trim((string) ($record['order_no'] ?? '')); + if ($orderNo === '') { + return ''; + } + + $order = $this->payOrderRepository->findByReceiptChannelOrder($this->receiptChannelIds(), $orderNo, ['pay_no']); + + return $order ? (string) $order->pay_no : ''; + } + + /** + * 通过流水金额定位支付单。 + * + * 只查同一插件配置账号下仍在有效期内的可变状态订单。若支付方式字段存在, + * 会进一步按支付方式过滤。多条候选时,选取离流水支付时间最近的订单并记录日志。 + * + * @param array $record 流水记录 + * @return string 支付单号 + */ + private function locatePayNoByAmount(array $record): string + { + $amount = $this->moneyToCents((string) ($record['price'] ?? '')); + $paidAt = $this->paidAtTimestamp($record); + if ($paidAt === null) { + throw new PaymentException('金额流水支付时间不能为空', 40200, ['record' => $record]); + } + + $orders = $this->payOrderRepository->listMutableReceiptOrdersByAmount( + $this->receiptChannelIds(), + $amount, + $this->payTypeIdFromRecord($record), + date('Y-m-d H:i:s'), + ['pay_no', 'request_at', 'expire_at'] + ) + ->filter(fn (PayOrder $payOrder): bool => $this->paidAtInOrderWindow($payOrder, $paidAt)) + ->values(); + if ($orders->isEmpty()) { + throw new PaymentException('金额流水未匹配到支付单', 40200, [ + 'amount' => FormatHelper::amount($amount), + 'paid_at' => date('Y-m-d H:i:s', $paidAt), + 'record' => $record, + ]); + } + + if ($orders->count() > 1) { + Log::warning(sprintf( + '[PostarReceiptPayment] 金额流水匹配到多笔订单,按时间最近选择 amount=%s count=%d', + FormatHelper::amount($amount), + $orders->count() + )); + } + + return $this->closestPayNo($orders->all(), $paidAt); + } + + /** + * 通过流水备注定位支付单。 + * + * 优先读取备注码缓存;缓存失效时,再回查有效订单 ext_json 中的 remark_code, + * 用于处理缓存短暂不可用或重启后仍在订单有效期内的情况。 + * + * @param array $record 流水记录 + * @return string 支付单号 + */ + private function locatePayNoByRemark(array $record): string + { + $remarkCode = $this->remarkCodeFromRecord($record); + $amount = $this->moneyToCents((string) ($record['price'] ?? '')); + $paidAt = $this->paidAtTimestamp($record); + if ($paidAt === null) { + throw new PaymentException('备注流水支付时间不能为空', 40200, ['record' => $record]); + } + + $payNo = (string) Cache::get($this->remarkCacheKey($remarkCode), ''); + if ($payNo !== '') { + $payOrder = $this->payOrderRepository->findByPayNo($payNo, ['pay_no', 'pay_amount', 'request_at', 'expire_at', 'ext_json']); + if (!$payOrder || !$this->remarkOrderMatchesFlow($payOrder, $amount, $paidAt)) { + throw new PaymentException('付款备注匹配的支付单金额或时间不一致', 40200, [ + 'pay_no' => $payNo, + 'amount' => FormatHelper::amount($amount), + 'paid_at' => date('Y-m-d H:i:s', $paidAt), + 'record' => $record, + ]); + } + + return (string) $payOrder->pay_no; + } + + $candidates = $this->payOrderRepository->listMutableReceiptOrders( + $this->receiptChannelIds(), + date('Y-m-d H:i:s'), + ['pay_no', 'pay_amount', 'request_at', 'expire_at', 'ext_json'] + ) + ->filter(function (PayOrder $payOrder) use ($remarkCode, $amount, $paidAt): bool { + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + return (string) ($receiptMeta['remark_code'] ?? '') === $remarkCode + && $this->remarkOrderMatchesFlow($payOrder, $amount, $paidAt); + }) + ->values(); + + if ($candidates->isEmpty()) { + throw new PaymentException('付款备注已失效、金额不一致或不在订单有效期内', 40200, [ + 'remark_code' => $remarkCode, + 'amount' => FormatHelper::amount($amount), + 'paid_at' => date('Y-m-d H:i:s', $paidAt), + 'record' => $record, + ]); + } + + if ($candidates->count() > 1) { + Log::warning(sprintf( + '[PostarReceiptPayment] 备注流水匹配到多笔订单,按时间最近选择 remark=%s count=%d', + $remarkCode, + $candidates->count() + )); + } + + return $this->closestPayNo($candidates->all(), $paidAt); + } + + /** + * 从候选订单中选择离流水支付时间最近的一笔。 + * + * 该策略用于处理同一金额或同一备注码匹配到多笔有效订单的情况。 + * + * @param array $orders 候选订单 + * @param int|null $paidAt 支付时间戳 + * @return string 支付单号 + */ + private function closestPayNo(array $orders, ?int $paidAt): string + { + if ($paidAt === null) { + return (string) $orders[0]->pay_no; + } + + usort($orders, static function (PayOrder $left, PayOrder $right) use ($paidAt): int { + $leftTime = strtotime((string) $left->request_at) ?: 0; + $rightTime = strtotime((string) $right->request_at) ?: 0; + + return abs($leftTime - $paidAt) <=> abs($rightTime - $paidAt); + }); + + return (string) $orders[0]->pay_no; + } + + /** + * 判断流水支付时间是否落在订单有效期内。 + * + * 金额识别只认订单发起后、过期前的流水,避免历史同金额流水误确认当前订单。 + * + * @param PayOrder $payOrder 支付单 + * @param int $paidAt 流水支付时间戳 + * @return bool 是否在有效期内 + */ + private function paidAtInOrderWindow(PayOrder $payOrder, int $paidAt): bool + { + $requestAt = strtotime((string) $payOrder->request_at) ?: 0; + $expireAt = strtotime((string) $payOrder->expire_at) ?: 0; + + return $requestAt > 0 + && $expireAt > 0 + && $paidAt >= $requestAt + && $paidAt <= $expireAt; + } + + /** + * 判断备注流水是否满足订单金额和有效期。 + * + * 备注码只负责定位候选订单,实际确认还必须校验付款金额等于订单原始金额。 + * + * @param PayOrder $payOrder 支付单 + * @param int $amount 流水金额,单位分 + * @param int $paidAt 流水支付时间戳 + * @return bool 是否匹配 + */ + private function remarkOrderMatchesFlow(PayOrder $payOrder, int $amount, int $paidAt): bool + { + return $amount === $this->originalAmount($payOrder) + && $this->paidAtInOrderWindow($payOrder, $paidAt); + } + + /** + * 从队列消息中提取归一化流水记录。 + * + * receipt_watcher 队列通常传 `{record: {...}}`;HTTP 手工重放时也允许直接传流水字段。 + * + * @param array $payload 通知载荷 + * @return array 流水记录 + */ + private function recordPayload(array $payload): array + { + return isset($payload['record']) && is_array($payload['record']) + ? $payload['record'] + : $payload; + } + + /** + * 根据流水支付方式编码解析支付方式 ID。 + * + * 星驿付收款单可能同时支持支付宝、微信、银联等支付方式,流水中的 pay_type + * 用于进一步缩小金额匹配范围。 + * + * @param array $record 流水记录 + * @return int 支付方式ID + */ + private function payTypeIdFromRecord(array $record): int + { + $payType = trim((string) ($record['pay_type'] ?? '')); + if ($payType === '') { + return 0; + } + + $type = $this->paymentTypeRepository->findByCode($payType, ['id']); + return $type ? (int) $type->id : 0; + } + + /** + * 获取当前收款账号对应的所有通道 ID。 + * + * 同一个星驿付插件配置可以绑定多个支付方式通道,金额占用和流水匹配必须在同一 + * api_config_id 的所有通道内进行,而不是只看当前支付方式通道。 + * + * @return array 当前账号关联的通道ID + */ + private function receiptChannelIds(): array + { + $ids = $this->paymentChannelRepository->idsByPluginConfig( + (string) $this->getConfig('plugin_code', 'postar_receipt'), + (int) $this->getConfig('api_config_id') + ); + + return $ids !== [] ? $ids : [(int) $this->getConfig('channel_id')]; + } + + /** + * 申请 4 位备注码。 + * + * 备注码按账号维度加缓存,避免同一个收款账号的有效订单拿到相同识别码。 + * + * @param string $payNo 支付单号 + * @return string 备注码 + */ + private function allocateRemarkCode(string $payNo): string + { + for ($i = 0; $i < 30; $i++) { + $code = (string) random_int(1000, 9999); + $key = $this->remarkCacheKey($code); + if (!Cache::has($key)) { + Cache::set($key, $payNo, $this->receiptValidSeconds()); + return $code; + } + } + + throw new PaymentException('付款备注码已用尽,请稍后重试', 40200); + } + + /** + * 执行账号级互斥锁。 + * + * 金额变动和备注码都按账号维度分配。同一账号并发发起支付时必须串行处理, + * 否则可能出现两个订单拿到相同金额或相同备注码。 + * + * @param callable $callback 回调 + * @return mixed + */ + private function withAccountLock(callable $callback): mixed + { + $key = 'mpay_receipt_lock_' . $this->accountKey(); + $token = bin2hex(random_bytes(8)); + + for ($i = 0; $i < 20; $i++) { + if (!Cache::has($key)) { + Cache::set($key, $token, 10); + } + + if ((string) Cache::get($key) === $token) { + try { + return $callback(); + } finally { + if ((string) Cache::get($key) === $token) { + Cache::delete($key); + } + } + } + usleep(50000); + } + + throw new PaymentException('当前收款账号正在分配收款标识,请稍后重试', 40200); + } + + /** + * 计算本次识别过期时间。 + * + * @return string 过期时间 + */ + private function expireAt(): string + { + return date('Y-m-d H:i:s', time() + $this->receiptValidSeconds()); + } + + /** + * 从流水备注中提取 4 位识别码。 + * + * @param array $record 流水记录 + * @return string 备注码 + */ + private function remarkCodeFromRecord(array $record): string + { + $remark = trim((string) ($record['remark'] ?? '')); + if (preg_match('/(? $remark]); + } + + return (string) $matches[1]; + } + + /** + * 将平台金额字符串转换为分。 + * + * @param string $money 金额文本 + * @return int 金额,单位分 + */ + private function moneyToCents(string $money): int + { + $money = trim($money); + if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + throw new PaymentException('流水金额格式不合法', 40200, ['money' => $money]); + } + + [$integer, $fraction] = array_pad(explode('.', $money, 2), 2, ''); + return (int) $integer * 100 + (int) str_pad(substr($fraction, 0, 2), 2, '0'); + } + + /** + * 生成备注码缓存键。 + * + * @param string $code 备注码 + * @return string 缓存键 + */ + private function remarkCacheKey(string $code): string + { + return 'mpay_receipt_remark_' . $this->accountKey() . '_' . $code; + } + + /** + * 生成账号维度业务键。 + * + * 使用插件编码和 api_config_id,确保同一个收款账号下的多个支付方式共用金额和备注池。 + * + * @return string 账号键 + */ + private function accountKey(): string + { + return preg_replace( + '/[^A-Za-z0-9_\\-]/', + '_', + (string) $this->getConfig('plugin_code', 'postar_receipt') . '_' . (int) $this->getConfig('api_config_id') + ) ?: 'postar_receipt_0'; + } + + /** + * 生成渠道交易号。 + * + * receipt_watcher 已保证星驿付流水的 order_no 固定来自收款单 `orderId`。 + * + * @param array $record 流水记录 + * @return string 第三方流水号 + */ + private function channelTradeNo(array $record): string + { + $orderNo = trim((string) ($record['order_no'] ?? '')); + if ($orderNo === '') { + throw new PaymentException('流水订单号不能为空', 40200, ['record' => $record]); + } + + return substr($orderNo, 0, 64); + } + + /** + * 从流水中解析支付时间。 + * + * @param array $record 流水记录 + * @return string|null 支付时间 + */ + private function paidAtFromRecord(array $record): ?string + { + $timestamp = $this->paidAtTimestamp($record); + + return $timestamp !== null ? date('Y-m-d H:i:s', $timestamp) : null; + } + + /** + * 从流水中解析支付时间戳。 + * + * 支持秒级时间戳、毫秒级时间戳和可被 strtotime 识别的时间字符串。 + * + * @param array $record 流水记录 + * @return int|null 支付时间戳 + */ + private function paidAtTimestamp(array $record): ?int + { + $paidAt = $record['paid_at'] ?? null; + if (is_numeric($paidAt)) { + $timestamp = (int) $paidAt; + return $timestamp > 10000000000 ? (int) floor($timestamp / 1000) : $timestamp; + } + + $text = trim((string) $paidAt); + if ($text === '') { + return null; + } + + $timestamp = strtotime($text); + return $timestamp !== false ? $timestamp : null; + } +} diff --git a/app/common/payment/ShouQianBaReceiptPayment.php b/app/common/payment/ShouQianBaReceiptPayment.php new file mode 100644 index 0000000..bc3bdd3 --- /dev/null +++ b/app/common/payment/ShouQianBaReceiptPayment.php @@ -0,0 +1,1055 @@ + + */ + protected array $paymentInfo = [ + 'code' => 'shouqianba_receipt', + 'name' => '收钱吧二维码牌收款', + 'author' => 'MPAY', + 'version' => '1.0.0', + 'pay_types' => ['alipay', 'wxpay', 'unionpay'], + 'transfer_types' => [], + 'config_schema' => [ + [ + 'type' => 'radio', + 'field' => 'receipt_match_mode', + 'title' => '订单匹配模式', + 'value' => 'amount', + 'options' => [ + ['label' => '金额变动', 'value' => 'amount'], + ['label' => '付款备注', 'value' => 'remark'], + ], + 'validate' => [ + ['required' => true, 'message' => '订单匹配模式不能为空'], + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'receipt_valid_seconds', + 'title' => '识别有效期(秒)', + 'value' => 300, + 'props' => [ + 'min' => 60, + 'max' => 1800, + 'step' => 60, + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'amount_offset_max', + 'title' => '最大金额偏移(分)', + 'value' => 99, + 'props' => [ + 'min' => 0, + 'max' => 99, + 'step' => 1, + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'receipt_watcher_query_interval_seconds', + 'title' => '账号查询间隔(秒)', + 'value' => 3, + 'props' => [ + 'min' => 2, + 'max' => 60, + 'step' => 1, + ], + ], + [ + 'type' => 'input', + 'field' => 'watcher_username', + 'title' => '平台登录账号', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入收钱吧后台登录账号', + ], + 'validate' => [ + ['required' => true, 'message' => '平台登录账号不能为空'], + ], + ], + [ + 'type' => 'password', + 'field' => 'watcher_password', + 'title' => '平台登录密码', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入收钱吧后台登录密码', + ], + 'validate' => [ + ['required' => true, 'message' => '平台登录密码不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'receipt_account_no', + 'title' => '收款账号标识', + 'value' => '', + 'props' => [ + 'placeholder' => '用于 receipt_watcher 区分账号,可填门店号或登录账号', + ], + ], + [ + 'type' => 'input', + 'field' => 'receipt_terminal_no', + 'title' => '收款终端号', + 'value' => '', + 'props' => [ + 'placeholder' => '可选,用于筛选平台流水中的终端号', + ], + ], + [ + 'type' => 'textarea', + 'field' => 'receipt_qrcode_content', + 'title' => '二维码牌内容', + 'value' => '', + 'props' => [ + 'placeholder' => '可填写二维码解析后的内容,优先用于收银台展示', + 'rows' => 4, + ], + ], + [ + 'type' => 'input', + 'field' => 'receipt_qrcode_image', + 'title' => '二维码牌图片', + 'value' => '', + 'props' => [ + 'placeholder' => '二维码图片 URL,未配置二维码内容时使用', + ], + ], + ], + ]; + + /** + * 发起二维码牌收款。 + * + * 这里不会调用上游 API,只做三件事: + * 1. 根据配置选择金额变动或付款备注模式。 + * 2. 写入本次识别所需的订单元数据和过期时间。 + * 3. 返回收银台 `receiptQrcode` 页面需要的二维码、金额、备注码和倒计时参数。 + * + * @param array $order 标准插件下单参数 + * @return array + */ + public function pay(array $order): array + { + $payNo = (string) $order['pay_no']; + $mode = $this->receiptMatchMode(); + $prepared = $mode === 'remark' + ? $this->prepareRemarkReceipt($payNo) + : $this->prepareAmountReceipt($payNo); + + $qrcode = trim((string) $this->getConfig('receipt_qrcode_content', '')); + $image = trim((string) $this->getConfig('receipt_qrcode_image', '')); + if ($qrcode === '' && $image === '') { + throw new PaymentException('收钱吧二维码牌插件未配置收款码', 40200); + } + + $params = [ + '_page' => 'receiptQrcode', + 'amount' => FormatHelper::amount((int) $prepared['pay_amount']), + 'original_amount' => FormatHelper::amount((int) $prepared['original_amount']), + 'receipt_match_mode' => $mode, + 'receipt_valid_seconds' => $this->receiptValidSeconds(), + 'expire_at' => (string) $prepared['expire_at'], + 'expire_at_timestamp' => (int) strtotime((string) $prepared['expire_at']), + 'description' => $mode === 'remark' + ? '请扫码付款,并在付款备注中填写识别码。' + : '请扫码付款,并按页面金额完成付款。', + ]; + if ($mode === 'remark') { + $params['remark_code'] = (string) $prepared['remark_code']; + $params['tips'] = '付款备注:' . (string) $prepared['remark_code']; + } + + if ($qrcode !== '') { + $params['qrcode'] = $qrcode; + } + if ($image !== '') { + $params['qrcode_image'] = $image; + } + + return $this->payResult($params, $payNo, (string) ($order['pay_type_code'] ?? '')); + } + + /** + * 主动查单由 receipt_watcher 完成,这里保持支付中。 + * + * 支付运行时的主动查单会调用插件 query(),但二维码牌没有标准上游查单接口。 + * 真正的流水查询由 Python receipt_watcher 负责,本方法只返回 pending,避免误推进状态。 + * + * @param array $order 订单参数 + * @return array + */ + public function query(array $order): array + { + return [ + 'success' => true, + 'status' => PaymentPluginStatusConstant::PENDING, + 'channel_order_no' => (string) ($order['channel_order_no'] ?? $order['pay_no'] ?? ''), + 'channel_trade_no' => (string) ($order['channel_trade_no'] ?? $order['pay_no'] ?? ''), + 'message' => '等待 receipt_watcher 查询收钱吧流水', + ]; + } + + /** + * 二维码牌无上游关单接口。 + * + * @param array $order 订单参数 + * @return array + */ + public function close(array $order): array + { + return [ + 'success' => true, + 'msg' => '收钱吧二维码牌收款无需上游关单', + ]; + } + + /** + * 二维码牌不支持接口退款。 + * + * @param array $order 订单参数 + * @return array + */ + public function refund(array $order): array + { + throw new PaymentException('收钱吧二维码牌收款不支持接口退款', 40200); + } + + /** + * HTTP 手工通知入口。 + * + * 该插件正式链路是 Redis 队列数组载荷。这里保留 HTTP notify() 是为了人工重放 + * 或调试同一份归一化流水,内部直接复用 notifyPayload(),不再单独维护两套解析逻辑。 + * + * @param Request $request 请求对象 + * @return array + */ + public function notify(Request $request): array + { + return $this->notifyPayload((array) $request->all()); + } + + /** + * 根据归一化流水定位支付单。 + * + * ChannelNotifyPayloadInterface 的第一阶段:只确认这条流水对应哪个 pay_no。 + * 不在这里推进订单状态,也不写回调日志,后续由服务层再调用 notifyPayload()。 + * + * @param array $payload 通知载荷 + * @return array{pay_no:string} + */ + public function channelNotifyPayload(array $payload): array + { + return ['pay_no' => $this->locatePayNo($payload)]; + } + + /** + * 解析归一化流水为标准支付成功通知。 + * + * ChannelNotifyPayloadInterface 的第二阶段:生成标准插件通知结果。 + * 服务层会校验返回结构,并复用统一的订单状态推进、回调日志和商户通知链路。 + * + * 注意:金额变动模式下,支付单 pay_amount 在这里恢复为原始金额;流水中的实际付款金额 + * 仅写入 ext_json.personal_receipt.notified_amount 供排查。 + * + * @param array $payload 通知载荷 + * @return array + */ + public function notifyPayload(array $payload): array + { + $record = $this->recordPayload($payload); + $payNo = $this->locatePayNo($payload); + $tradeNo = $this->channelTradeNo($record); + $notifiedAmount = isset($record['price']) ? $this->moneyToCents((string) $record['price']) : null; + + $this->restoreOriginalPayAmount($payNo, $record, $tradeNo, $notifiedAmount); + + return [ + 'status' => PaymentPluginStatusConstant::SUCCESS, + 'pay_no' => $payNo, + 'message' => 'receipt_watcher 已确认收钱吧收款流水', + 'channel_order_no' => $tradeNo, + 'channel_trade_no' => $tradeNo, + 'channel_status' => 'receipt_watcher_received', + 'paid_at' => $this->paidAtFromRecord($record), + ]; + } + + public function notifySuccess(): string|Response + { + return 'success'; + } + + public function notifyFail(): string|Response + { + return 'fail'; + } + + /** + * 组装收银台承接返回。 + * + * pay_product/pay_action 用于前端和日志识别这是二维码牌网页流水监听场景; + * chan_order_no 暂用系统支付单号,因为平台真实流水号要等 receipt_watcher 查询后才知道。 + * + * @param array $params 承接参数 + * @param string $payNo 支付单号 + * @param string $payType 支付方式 + * @return array + */ + private function payResult(array $params, string $payNo, string $payType): array + { + $payType = trim($payType) !== '' ? trim($payType) : 'alipay'; + + return [ + 'pay_page' => 'page', + 'pay_type' => $payType, + 'pay_product' => 'receipt_plate', + 'pay_action' => 'web_watcher', + 'pay_params' => $params, + 'chan_order_no' => $payNo, + 'chan_trade_no' => '', + ]; + } + + /** + * 读取订单匹配模式。 + * + * 只允许 `amount` 和 `remark` 两种模式,非法配置值按 `amount` 处理。 + * + * @return string 匹配模式 + */ + private function receiptMatchMode(): string + { + return (string) $this->getConfig('receipt_match_mode', 'amount') === 'remark' ? 'remark' : 'amount'; + } + + /** + * 读取收款识别有效期。 + * + * 有效期用于订单过期时间、金额占用窗口、备注码缓存时间。 + * + * @return int 有效期秒数 + */ + private function receiptValidSeconds(): int + { + return max(60, (int) $this->getConfig('receipt_valid_seconds', 300)); + } + + /** + * 读取最大金额偏移。 + * + * 单位是分。默认最多 +0.99,超过后直接失败,不继续扩大金额范围。 + * + * @return int 最大金额偏移,单位分 + */ + private function amountOffsetMax(): int + { + return min(99, max(0, (int) $this->getConfig('amount_offset_max', 99))); + } + + /** + * 准备金额变动收款参数。 + * + * 同一收款账号内,查找有效期内已经占用的金额,从原始金额开始按 0.01 递增寻找 + * 最小可用金额。例如 10.01 已过期时,新的订单可以重新使用 10.01。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareAmountReceipt(string $payNo): array + { + return $this->withAccountLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $usedAmounts = $this->payOrderRepository->listUsedReceiptAmounts( + $this->receiptChannelIds(), + $payNo, + date('Y-m-d H:i:s') + ); + + $used = array_fill_keys($usedAmounts, true); + $receiptAmount = 0; + for ($offset = 0; $offset <= $this->amountOffsetMax(); $offset++) { + $candidate = $originalAmount + $offset; + if (!isset($used[$candidate])) { + $receiptAmount = $candidate; + break; + } + } + + if ($receiptAmount <= 0) { + throw new PaymentException('当前账号可用金额偏移已用尽', 40200, [ + 'pay_no' => $payNo, + 'api_config_id' => (int) $this->getConfig('api_config_id'), + ]); + } + + $this->persistReceiptMeta($payOrder, [ + 'platform' => 'shouqianba_receipt', + 'mode' => 'amount', + 'original_amount' => $originalAmount, + 'receipt_amount' => $receiptAmount, + 'offset_amount' => $receiptAmount - $originalAmount, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $receiptAmount, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 准备备注收款参数。 + * + * 备注模式不改变实际付款金额,只为本次订单生成 4 位识别码。 + * receipt_watcher 查询到流水后,插件会从 remark 字段中提取该识别码定位订单。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareRemarkReceipt(string $payNo): array + { + return $this->withAccountLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $remarkCode = $this->allocateRemarkCode($payNo); + + $this->persistReceiptMeta($payOrder, [ + 'platform' => 'shouqianba_receipt', + 'mode' => 'remark', + 'original_amount' => $originalAmount, + 'receipt_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 加锁读取支付单。 + * + * pay() 准备识别信息和 notifyPayload() 恢复原始金额都需要在事务内锁定同一支付单, + * 避免并发通知或重复发起导致金额和扩展信息交叉覆盖。 + * + * @param string $payNo 支付单号 + * @return PayOrder + */ + private function lockedPayOrder(string $payNo): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new PaymentException('支付单不存在', 40402, ['pay_no' => $payNo]); + } + + return $payOrder; + } + + /** + * 写入收款识别元数据。 + * + * personal_receipt 是该类插件的专用扩展分区: + * - original_amount 保存业务原始金额。 + * - receipt_amount 保存当次页面提示付款金额。 + * - offset_amount 保存金额偏移。 + * - remark_code 保存备注识别码。 + * - expire_at 保存本次识别有效期。 + * + * @param PayOrder $payOrder 支付单 + * @param array $meta 元数据 + * @return void + */ + private function persistReceiptMeta(PayOrder $payOrder, array $meta): void + { + $payOrder->pay_amount = (int) $meta['receipt_amount']; + $payOrder->expire_at = (string) $meta['expire_at']; + $extJson = (array) ($payOrder->ext_json ?? []); + $extJson['personal_receipt'] = $meta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + } + + /** + * 读取原始订单金额。 + * + * 支付单可能已被金额变动模式临时改成识别金额,因此优先从 + * ext_json.personal_receipt.original_amount 取业务原始金额。 + * + * @param PayOrder $payOrder 支付单 + * @return int 原始订单金额,单位分 + */ + private function originalAmount(PayOrder $payOrder): int + { + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + return $originalAmount > 0 ? $originalAmount : (int) $payOrder->pay_amount; + } + + /** + * 通知识别完成后恢复支付单金额。 + * + * 这是金额变动模式的关键收口:流水确认后先把 pay_amount 恢复为 original_amount, + * 再返回标准成功结果,确保后续账户入账、清算、统计都按业务订单原始金额计算。 + * + * @param string $payNo 支付单号 + * @param array $record 流水记录 + * @param string $tradeNo 第三方流水号 + * @param int|null $notifiedAmount 实际付款金额 + * @return void + */ + private function restoreOriginalPayAmount(string $payNo, array $record, string $tradeNo, ?int $notifiedAmount): void + { + Db::transaction(function () use ($payNo, $record, $tradeNo, $notifiedAmount): void { + $payOrder = $this->lockedPayOrder($payNo); + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + if ($originalAmount > 0) { + $payOrder->pay_amount = $originalAmount; + } + + $receiptMeta['notified_at'] = $this->paidAtFromRecord($record) ?? date('Y-m-d H:i:s'); + $receiptMeta['channel_trade_no'] = $tradeNo; + $receiptMeta['record'] = $record; + if ($notifiedAmount !== null) { + $receiptMeta['notified_amount'] = $notifiedAmount; + } + $extJson['personal_receipt'] = $receiptMeta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + }); + } + + /** + * 定位支付单号。 + * + * 匹配顺序: + * 1. 如果流水带平台订单号,优先按 channel_order_no/channel_trade_no 匹配。 + * 2. 否则按当前插件配置走付款备注或金额变动模式。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNo(array $payload): string + { + $record = $this->recordPayload($payload); + $directPayNo = $this->locatePayNoByChannelOrder($record); + if ($directPayNo !== '') { + return $directPayNo; + } + + return $this->receiptMatchMode() === 'remark' + ? $this->locatePayNoByRemark($record) + : $this->locatePayNoByAmount($record); + } + + /** + * 通过平台流水号定位支付单。 + * + * 已经成功处理过的流水会把平台订单号回填到 channel_order_no/channel_trade_no, + * 后续重复流水可以优先命中这里,减少金额或备注重复判断的不确定性。 + * + * @param array $record 流水记录 + * @return string 支付单号 + */ + private function locatePayNoByChannelOrder(array $record): string + { + $orderNo = trim((string) ($record['order_no'] ?? '')); + if ($orderNo === '') { + return ''; + } + + $order = $this->payOrderRepository->findByReceiptChannelOrder($this->receiptChannelIds(), $orderNo, ['pay_no']); + + return $order ? (string) $order->pay_no : ''; + } + + /** + * 通过流水金额定位支付单。 + * + * 只查同一插件配置账号下仍在有效期内的可变状态订单。若支付方式字段存在, + * 会进一步按支付方式过滤。多条候选时,选取离流水支付时间最近的订单并记录日志。 + * + * @param array $record 流水记录 + * @return string 支付单号 + */ + private function locatePayNoByAmount(array $record): string + { + $amount = $this->moneyToCents((string) ($record['price'] ?? '')); + $paidAt = $this->paidAtTimestamp($record); + if ($paidAt === null) { + throw new PaymentException('金额流水支付时间不能为空', 40200, ['record' => $record]); + } + + $orders = $this->payOrderRepository->listMutableReceiptOrdersByAmount( + $this->receiptChannelIds(), + $amount, + $this->payTypeIdFromRecord($record), + date('Y-m-d H:i:s'), + ['pay_no', 'request_at', 'expire_at'] + ) + ->filter(fn (PayOrder $payOrder): bool => $this->paidAtInOrderWindow($payOrder, $paidAt)) + ->values(); + if ($orders->isEmpty()) { + throw new PaymentException('金额流水未匹配到支付单', 40200, [ + 'amount' => FormatHelper::amount($amount), + 'paid_at' => date('Y-m-d H:i:s', $paidAt), + 'record' => $record, + ]); + } + + if ($orders->count() > 1) { + Log::warning(sprintf( + '[ShouQianBaReceiptPayment] 金额流水匹配到多笔订单,按时间最近选择 amount=%s count=%d', + FormatHelper::amount($amount), + $orders->count() + )); + } + + return $this->closestPayNo($orders->all(), $paidAt); + } + + /** + * 通过流水备注定位支付单。 + * + * 优先读取备注码缓存;缓存失效时,再回查有效订单 ext_json 中的 remark_code, + * 用于处理缓存短暂不可用或重启后仍在订单有效期内的情况。 + * + * @param array $record 流水记录 + * @return string 支付单号 + */ + private function locatePayNoByRemark(array $record): string + { + $remarkCode = $this->remarkCodeFromRecord($record); + $amount = $this->moneyToCents((string) ($record['price'] ?? '')); + $paidAt = $this->paidAtTimestamp($record); + if ($paidAt === null) { + throw new PaymentException('备注流水支付时间不能为空', 40200, ['record' => $record]); + } + + $payNo = (string) Cache::get($this->remarkCacheKey($remarkCode), ''); + if ($payNo !== '') { + $payOrder = $this->payOrderRepository->findByPayNo($payNo, ['pay_no', 'pay_amount', 'request_at', 'expire_at', 'ext_json']); + if (!$payOrder || !$this->remarkOrderMatchesFlow($payOrder, $amount, $paidAt)) { + throw new PaymentException('付款备注匹配的支付单金额或时间不一致', 40200, [ + 'pay_no' => $payNo, + 'amount' => FormatHelper::amount($amount), + 'paid_at' => date('Y-m-d H:i:s', $paidAt), + 'record' => $record, + ]); + } + + return (string) $payOrder->pay_no; + } + + $candidates = $this->payOrderRepository->listMutableReceiptOrders( + $this->receiptChannelIds(), + date('Y-m-d H:i:s'), + ['pay_no', 'pay_amount', 'request_at', 'expire_at', 'ext_json'] + ) + ->filter(function (PayOrder $payOrder) use ($remarkCode, $amount, $paidAt): bool { + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + return (string) ($receiptMeta['remark_code'] ?? '') === $remarkCode + && $this->remarkOrderMatchesFlow($payOrder, $amount, $paidAt); + }) + ->values(); + + if ($candidates->isEmpty()) { + throw new PaymentException('付款备注已失效、金额不一致或不在订单有效期内', 40200, [ + 'remark_code' => $remarkCode, + 'amount' => FormatHelper::amount($amount), + 'paid_at' => date('Y-m-d H:i:s', $paidAt), + 'record' => $record, + ]); + } + + if ($candidates->count() > 1) { + Log::warning(sprintf( + '[ShouQianBaReceiptPayment] 备注流水匹配到多笔订单,按时间最近选择 remark=%s count=%d', + $remarkCode, + $candidates->count() + )); + } + + return $this->closestPayNo($candidates->all(), $paidAt); + } + + /** + * 从候选订单中选择离流水支付时间最近的一笔。 + * + * 该策略用于处理同一金额或同一备注码匹配到多笔有效订单的情况。 + * + * @param array $orders 候选订单 + * @param int|null $paidAt 支付时间戳 + * @return string 支付单号 + */ + private function closestPayNo(array $orders, ?int $paidAt): string + { + if ($paidAt === null) { + return (string) $orders[0]->pay_no; + } + + usort($orders, static function (PayOrder $left, PayOrder $right) use ($paidAt): int { + $leftTime = strtotime((string) $left->request_at) ?: 0; + $rightTime = strtotime((string) $right->request_at) ?: 0; + + return abs($leftTime - $paidAt) <=> abs($rightTime - $paidAt); + }); + + return (string) $orders[0]->pay_no; + } + + /** + * 判断流水支付时间是否落在订单有效期内。 + * + * 金额识别只认订单发起后、过期前的流水,避免历史同金额流水误确认当前订单。 + * + * @param PayOrder $payOrder 支付单 + * @param int $paidAt 流水支付时间戳 + * @return bool 是否在有效期内 + */ + private function paidAtInOrderWindow(PayOrder $payOrder, int $paidAt): bool + { + $requestAt = strtotime((string) $payOrder->request_at) ?: 0; + $expireAt = strtotime((string) $payOrder->expire_at) ?: 0; + + return $requestAt > 0 + && $expireAt > 0 + && $paidAt >= $requestAt + && $paidAt <= $expireAt; + } + + /** + * 判断备注流水是否满足订单金额和有效期。 + * + * 备注码只负责定位候选订单,实际确认还必须校验付款金额等于订单原始金额。 + * + * @param PayOrder $payOrder 支付单 + * @param int $amount 流水金额,单位分 + * @param int $paidAt 流水支付时间戳 + * @return bool 是否匹配 + */ + private function remarkOrderMatchesFlow(PayOrder $payOrder, int $amount, int $paidAt): bool + { + return $amount === $this->originalAmount($payOrder) + && $this->paidAtInOrderWindow($payOrder, $paidAt); + } + + /** + * 从队列消息中提取归一化流水记录。 + * + * receipt_watcher 队列通常传 `{record: {...}}`;HTTP 手工重放时也允许直接传流水字段。 + * + * @param array $payload 通知载荷 + * @return array 流水记录 + */ + private function recordPayload(array $payload): array + { + return isset($payload['record']) && is_array($payload['record']) + ? $payload['record'] + : $payload; + } + + /** + * 根据流水支付方式编码解析支付方式 ID。 + * + * 收钱吧二维码牌可能同时支持支付宝、微信、银联等支付方式,流水中的 pay_type + * 用于进一步缩小金额匹配范围。 + * + * @param array $record 流水记录 + * @return int 支付方式ID + */ + private function payTypeIdFromRecord(array $record): int + { + $payType = trim((string) ($record['pay_type'] ?? '')); + if ($payType === '') { + return 0; + } + + $type = $this->paymentTypeRepository->findByCode($payType, ['id']); + return $type ? (int) $type->id : 0; + } + + /** + * 获取当前收款账号对应的所有通道 ID。 + * + * 同一个收钱吧插件配置可以绑定多个支付方式通道,金额占用和流水匹配必须在同一 + * api_config_id 的所有通道内进行,而不是只看当前支付方式通道。 + * + * @return array 当前账号关联的通道ID + */ + private function receiptChannelIds(): array + { + $ids = $this->paymentChannelRepository->idsByPluginConfig( + (string) $this->getConfig('plugin_code', 'shouqianba_receipt'), + (int) $this->getConfig('api_config_id') + ); + + return $ids !== [] ? $ids : [(int) $this->getConfig('channel_id')]; + } + + /** + * 申请 4 位备注码。 + * + * 备注码按账号维度加缓存,避免同一个收款账号的有效订单拿到相同识别码。 + * + * @param string $payNo 支付单号 + * @return string 备注码 + */ + private function allocateRemarkCode(string $payNo): string + { + for ($i = 0; $i < 30; $i++) { + $code = (string) random_int(1000, 9999); + $key = $this->remarkCacheKey($code); + if (!Cache::has($key)) { + Cache::set($key, $payNo, $this->receiptValidSeconds()); + return $code; + } + } + + throw new PaymentException('付款备注码已用尽,请稍后重试', 40200); + } + + /** + * 执行账号级互斥锁。 + * + * 金额变动和备注码都按账号维度分配。同一账号并发发起支付时必须串行处理, + * 否则可能出现两个订单拿到相同金额或相同备注码。 + * + * @param callable $callback 回调 + * @return mixed + */ + private function withAccountLock(callable $callback): mixed + { + $key = 'mpay_receipt_lock_' . $this->accountKey(); + $token = bin2hex(random_bytes(8)); + + for ($i = 0; $i < 20; $i++) { + if (!Cache::has($key)) { + Cache::set($key, $token, 10); + } + + if ((string) Cache::get($key) === $token) { + try { + return $callback(); + } finally { + if ((string) Cache::get($key) === $token) { + Cache::delete($key); + } + } + } + usleep(50000); + } + + throw new PaymentException('当前收款账号正在分配收款标识,请稍后重试', 40200); + } + + /** + * 计算本次识别过期时间。 + * + * @return string 过期时间 + */ + private function expireAt(): string + { + return date('Y-m-d H:i:s', time() + $this->receiptValidSeconds()); + } + + /** + * 从流水备注中提取 4 位识别码。 + * + * @param array $record 流水记录 + * @return string 备注码 + */ + private function remarkCodeFromRecord(array $record): string + { + $remark = trim((string) ($record['remark'] ?? '')); + if (preg_match('/(? $remark]); + } + + return (string) $matches[1]; + } + + /** + * 将平台金额字符串转换为分。 + * + * @param string $money 金额文本 + * @return int 金额,单位分 + */ + private function moneyToCents(string $money): int + { + $money = trim($money); + if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + throw new PaymentException('流水金额格式不合法', 40200, ['money' => $money]); + } + + [$integer, $fraction] = array_pad(explode('.', $money, 2), 2, ''); + return (int) $integer * 100 + (int) str_pad(substr($fraction, 0, 2), 2, '0'); + } + + /** + * 生成备注码缓存键。 + * + * @param string $code 备注码 + * @return string 缓存键 + */ + private function remarkCacheKey(string $code): string + { + return 'mpay_receipt_remark_' . $this->accountKey() . '_' . $code; + } + + /** + * 生成账号维度业务键。 + * + * 使用插件编码和 api_config_id,确保同一个收款账号下的多个支付方式共用金额和备注池。 + * + * @return string 账号键 + */ + private function accountKey(): string + { + return preg_replace( + '/[^A-Za-z0-9_\\-]/', + '_', + (string) $this->getConfig('plugin_code', 'shouqianba_receipt') . '_' . (int) $this->getConfig('api_config_id') + ) ?: 'shouqianba_receipt_0'; + } + + /** + * 生成渠道交易号。 + * + * receipt_watcher 已保证收钱吧流水的 order_no 固定来自 order_sn。 + * + * @param array $record 流水记录 + * @return string 第三方流水号 + */ + private function channelTradeNo(array $record): string + { + $orderNo = trim((string) ($record['order_no'] ?? '')); + if ($orderNo === '') { + throw new PaymentException('流水订单号不能为空', 40200, ['record' => $record]); + } + + return substr($orderNo, 0, 64); + } + + /** + * 从流水中解析支付时间。 + * + * @param array $record 流水记录 + * @return string|null 支付时间 + */ + private function paidAtFromRecord(array $record): ?string + { + $timestamp = $this->paidAtTimestamp($record); + + return $timestamp !== null ? date('Y-m-d H:i:s', $timestamp) : null; + } + + /** + * 从流水中解析支付时间戳。 + * + * 支持秒级时间戳、毫秒级时间戳和可被 strtotime 识别的时间字符串。 + * + * @param array $record 流水记录 + * @return int|null 支付时间戳 + */ + private function paidAtTimestamp(array $record): ?int + { + $paidAt = $record['paid_at'] ?? null; + if (is_numeric($paidAt)) { + $timestamp = (int) $paidAt; + return $timestamp > 10000000000 ? (int) floor($timestamp / 1000) : $timestamp; + } + + $text = trim((string) $paidAt); + if ($text === '') { + return null; + } + + $timestamp = strtotime($text); + return $timestamp !== false ? $timestamp : null; + } +} diff --git a/app/common/payment/WechatReceiptPayment.php b/app/common/payment/WechatReceiptPayment.php new file mode 100644 index 0000000..049c429 --- /dev/null +++ b/app/common/payment/WechatReceiptPayment.php @@ -0,0 +1,780 @@ + + */ + protected array $paymentInfo = [ + 'code' => 'wxpay_receipt', + 'name' => '微信个人收款监听', + 'author' => 'MPAY', + 'version' => '1.0.0', + 'pay_types' => ['wxpay'], + 'transfer_types' => [], + 'config_schema' => [ + [ + 'type' => 'radio', + 'field' => 'receipt_match_mode', + 'title' => '订单匹配模式', + 'value' => 'amount', + 'options' => [ + ['label' => '金额变动', 'value' => 'amount'], + ['label' => '付款备注', 'value' => 'remark'], + ], + 'validate' => [ + ['required' => true, 'message' => '订单匹配模式不能为空'], + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'receipt_valid_seconds', + 'title' => '识别有效期(秒)', + 'value' => 300, + 'props' => [ + 'min' => 60, + 'max' => 1800, + 'step' => 60, + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'amount_offset_max', + 'title' => '最大金额偏移(分)', + 'value' => 99, + 'props' => [ + 'min' => 0, + 'max' => 99, + 'step' => 1, + ], + ], + [ + 'type' => 'input', + 'field' => 'sms_forwarder_secret', + 'title' => 'SmsForwarder密钥', + 'value' => '', + 'props' => [ + 'placeholder' => '用于校验 SmsForwarder sign', + 'type' => 'password', + ], + 'validate' => [ + ['required' => true, 'message' => 'SmsForwarder密钥不能为空'], + ], + ], + [ + 'type' => 'inputNumber', + 'field' => 'sms_forwarder_time_tolerance', + 'title' => '签名时间容差(秒)', + 'value' => 300, + 'props' => [ + 'min' => 30, + 'max' => 1800, + 'step' => 30, + ], + ], + [ + 'type' => 'textarea', + 'field' => 'receipt_qrcode_content', + 'title' => '微信收款码内容', + 'value' => '', + 'props' => [ + 'placeholder' => '可填写微信收款码解码后的内容,优先用于二维码承接页', + 'rows' => 4, + ], + ], + [ + 'type' => 'input', + 'field' => 'receipt_qrcode_image', + 'title' => '微信收款码图片', + 'value' => '', + 'props' => [ + 'placeholder' => '收款码图片 URL,未配置收款码内容时使用', + ], + ], + ], + ]; + + /** + * 发起个人收款。 + * + * 不调用微信官方支付接口,只准备收银台二维码承接参数。 + * 金额模式会分配一个有效期内唯一金额;备注模式会分配一个 4 位备注码缓存。 + * + * @param array $order 标准插件下单参数 + * @return array + */ + public function pay(array $order): array + { + $payNo = (string) $order['pay_no']; + $mode = $this->receiptMatchMode(); + $prepared = $mode === 'remark' + ? $this->prepareRemarkReceipt($payNo) + : $this->prepareAmountReceipt($payNo); + + $qrcode = trim((string) $this->getConfig('receipt_qrcode_content', '')); + $image = trim((string) $this->getConfig('receipt_qrcode_image', '')); + if ($qrcode === '' && $image === '') { + throw new PaymentException('微信个人收款插件未配置收款码', 40200); + } + + $params = [ + '_page' => 'receiptQrcode', + 'amount' => FormatHelper::amount((int) $prepared['pay_amount']), + 'original_amount' => FormatHelper::amount((int) $prepared['original_amount']), + 'receipt_match_mode' => $mode, + 'receipt_valid_seconds' => $this->receiptValidSeconds(), + 'expire_at' => (string) $prepared['expire_at'], + 'expire_at_timestamp' => (int) strtotime((string) $prepared['expire_at']), + 'description' => $mode === 'remark' + ? '请使用微信扫码,并在付款备注中填写识别码。' + : '请使用微信扫码,并按页面金额完成付款。', + ]; + if ($mode === 'remark') { + $params['remark_code'] = (string) $prepared['remark_code']; + $params['tips'] = '付款备注:' . (string) $prepared['remark_code']; + } + + if ($qrcode !== '') { + $params['qrcode'] = $qrcode; + } + if ($image !== '') { + $params['qrcode_image'] = $image; + } + + return $this->payResult('page', $params, $payNo); + } + + /** + * 通道级通知定位支付单。 + * + * 这里是第一阶段,只根据 SmsForwarder 内容确认 pay_no,不做支付成功处理。 + * 后续验签、幂等、订单状态流转仍由支付服务层继续调用 notify() 完成。 + * + * @param Request $request 请求对象 + * @return array{pay_no:string} + */ + public function channelNotify(Request $request): array + { + $payload = $this->verifiedSmsForwarderPayload($request); + + return ['pay_no' => $this->locatePayNo($payload)]; + } + + /** + * 主动查单不适用于通知栏监听,保持支付中。 + * + * @param array $order 订单参数 + * @return array + */ + public function query(array $order): array + { + return [ + 'success' => true, + 'status' => PaymentPluginStatusConstant::PENDING, + 'channel_order_no' => (string) ($order['channel_order_no'] ?? $order['pay_no'] ?? ''), + 'channel_trade_no' => (string) ($order['channel_trade_no'] ?? $order['pay_no'] ?? ''), + 'message' => '个人收款监听通道等待 SmsForwarder 通知', + ]; + } + + /** + * 个人收款无上游关单接口。 + * + * @param array $order 订单参数 + * @return array + */ + public function close(array $order): array + { + return [ + 'success' => true, + 'msg' => '个人收款监听通道无需上游关单', + ]; + } + + /** + * 个人收款不支持接口退款。 + * + * @param array $order 订单参数 + * @return array + */ + public function refund(array $order): array + { + throw new PaymentException('微信个人收款监听不支持接口退款', 40200); + } + + /** + * 解析并校验 SmsForwarder 通知。 + * + * 这是第二阶段:服务层确认 pay_no 后调用。插件再次校验通知并恢复原始金额, + * 然后返回统一的支付成功结果给核心支付流程。 + * + * @param Request $request 回调请求 + * @return array + */ + public function notify(Request $request): array + { + $payload = $this->verifiedSmsForwarderPayload($request); + $content = (string) $payload['content']; + $tradeNo = $this->channelTradeNo($payload); + $payNo = $this->locatePayNo($payload); + $notifiedAmount = $this->receiptMatchMode() === 'amount' + ? $this->amountFromContent($content) + : null; + + $this->restoreOriginalPayAmount($payNo, $payload, $tradeNo, $notifiedAmount); + + return [ + 'status' => PaymentPluginStatusConstant::SUCCESS, + 'pay_no' => $payNo, + 'message' => mb_strcut(preg_replace('/\s+/', ' ', $content) ?? $content, 0, 180, 'UTF-8'), + 'channel_order_no' => $tradeNo, + 'channel_trade_no' => $tradeNo, + 'channel_status' => 'sms_forwarder_received', + 'paid_at' => $this->paidAtFromPayload($payload), + ]; + } + + /** + * 返回监听工具要求的成功应答。 + */ + public function notifySuccess(): string|Response + { + return 'success'; + } + + /** + * 返回监听工具要求的失败应答。 + */ + public function notifyFail(): string|Response + { + return 'fail'; + } + + /** + * 包装个人收款承接页返回值。 + * + * @param string $payPage 承接页类型 + * @param array $params 承接参数 + * @param string $payNo 支付单号 + * @return array + */ + private function payResult(string $payPage, array $params, string $payNo): array + { + return [ + 'pay_page' => $payPage, + 'pay_type' => 'wxpay', + 'pay_product' => 'receipt', + 'pay_action' => 'sms_forwarder', + 'pay_params' => $params, + 'chan_order_no' => $payNo, + 'chan_trade_no' => '', + ]; + } + + /** + * 读取后台配置的订单匹配模式。 + * + * @return string 匹配模式 + */ + private function receiptMatchMode(): string + { + return (string) $this->getConfig('receipt_match_mode', 'amount') === 'remark' ? 'remark' : 'amount'; + } + + /** + * 读取识别有效期,最低 60 秒。 + * + * @return int 有效期秒数 + */ + private function receiptValidSeconds(): int + { + return max(60, (int) $this->getConfig('receipt_valid_seconds', 300)); + } + + /** + * 读取最大金额偏移,单位分。 + * + * @return int 最大金额偏移,单位分 + */ + private function amountOffsetMax(): int + { + return min(99, max(0, (int) $this->getConfig('amount_offset_max', 99))); + } + + /** + * 准备金额变动收款参数。 + * + * 在同一通道锁内扫描有效期内待支付订单,始终选择最小可用偏移金额。 + * 例如 10.01 已过期时,会重新使用 10.01,而不是继续累加到 10.04。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareAmountReceipt(string $payNo): array + { + return $this->withChannelLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $usedAmounts = $this->payOrderRepository->listUsedReceiptAmounts( + [(int) $this->getConfig('channel_id')], + $payNo, + date('Y-m-d H:i:s') + ); + + $used = array_fill_keys($usedAmounts, true); + $receiptAmount = 0; + for ($offset = 0; $offset <= $this->amountOffsetMax(); $offset++) { + $candidate = $originalAmount + $offset; + if (!isset($used[$candidate])) { + $receiptAmount = $candidate; + break; + } + } + + if ($receiptAmount <= 0) { + throw new PaymentException('当前通道可用金额偏移已用尽', 40200, [ + 'pay_no' => $payNo, + 'channel_id' => (int) $this->getConfig('channel_id'), + ]); + } + + $this->persistReceiptMeta($payOrder, [ + 'mode' => 'amount', + 'original_amount' => $originalAmount, + 'receipt_amount' => $receiptAmount, + 'offset_amount' => $receiptAmount - $originalAmount, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $receiptAmount, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 准备备注收款参数。 + * + * 为当前支付单分配 4 位备注码并写入缓存,通知时通过备注码反查 pay_no。 + * + * @param string $payNo 支付单号 + * @return array + */ + private function prepareRemarkReceipt(string $payNo): array + { + return $this->withChannelLock(function () use ($payNo): array { + return Db::transaction(function () use ($payNo): array { + $payOrder = $this->lockedPayOrder($payNo); + $originalAmount = $this->originalAmount($payOrder); + $expireAt = $this->expireAt(); + $remarkCode = $this->allocateRemarkCode($payNo); + + $this->persistReceiptMeta($payOrder, [ + 'mode' => 'remark', + 'original_amount' => $originalAmount, + 'receipt_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]); + + return [ + 'original_amount' => $originalAmount, + 'pay_amount' => $originalAmount, + 'remark_code' => $remarkCode, + 'expire_at' => $expireAt, + ]; + }); + }); + } + + /** + * 加锁读取支付单。 + * + * @param string $payNo 支付单号 + * @return PayOrder + */ + private function lockedPayOrder(string $payNo): PayOrder + { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new PaymentException('支付单不存在', 40402, ['pay_no' => $payNo]); + } + + return $payOrder; + } + + /** + * 写入个人收款元数据。 + * + * 金额模式会临时改写 pay_amount 作为识别金额,原始金额保存在 ext_json.personal_receipt。 + * + * @param PayOrder $payOrder 支付单 + * @param array $meta 收款元数据 + * @return void + */ + private function persistReceiptMeta(PayOrder $payOrder, array $meta): void + { + $payOrder->pay_amount = (int) $meta['receipt_amount']; + $payOrder->expire_at = (string) $meta['expire_at']; + $extJson = (array) ($payOrder->ext_json ?? []); + $extJson['personal_receipt'] = $meta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + } + + /** + * 获取原始订单金额。 + * + * 二次发起或刷新承接页时,优先从 ext_json 读取,避免把上一次偏移金额当成原始金额。 + * + * @param PayOrder $payOrder 支付单 + * @return int 原始订单金额,单位分 + */ + private function originalAmount(PayOrder $payOrder): int + { + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + return $originalAmount > 0 ? $originalAmount : (int) $payOrder->pay_amount; + } + + /** + * 通知识别完成后恢复支付单金额,避免变动金额进入业务单统计。 + * + * @param string $payNo 支付单号 + * @param array $payload 通知载荷 + * @param string $tradeNo 渠道交易号 + * @param int|null $notifiedAmount 通知中的实际付款金额 + * @return void + */ + private function restoreOriginalPayAmount(string $payNo, array $payload, string $tradeNo, ?int $notifiedAmount): void + { + Db::transaction(function () use ($payNo, $payload, $tradeNo, $notifiedAmount): void { + $payOrder = $this->lockedPayOrder($payNo); + $extJson = (array) ($payOrder->ext_json ?? []); + $receiptMeta = (array) ($extJson['personal_receipt'] ?? []); + $originalAmount = (int) ($receiptMeta['original_amount'] ?? 0); + + if ($originalAmount > 0) { + $payOrder->pay_amount = $originalAmount; + } + + $receiptMeta['notified_at'] = $this->paidAtFromPayload($payload) ?? date('Y-m-d H:i:s'); + $receiptMeta['channel_trade_no'] = $tradeNo; + if ($notifiedAmount !== null) { + $receiptMeta['notified_amount'] = $notifiedAmount; + } + $extJson['personal_receipt'] = $receiptMeta; + $payOrder->ext_json = $extJson; + $payOrder->save(); + }); + } + + /** + * 申请 4 位备注码。 + * + * 备注码缓存有效期与订单识别有效期一致,同一通道下短时间内不重复。 + * + * @param string $payNo 支付单号 + * @return string 备注码 + */ + private function allocateRemarkCode(string $payNo): string + { + for ($i = 0; $i < 30; $i++) { + $code = (string) random_int(1000, 9999); + $key = $this->remarkCacheKey($code); + if (!Cache::has($key)) { + Cache::set($key, $payNo, $this->receiptValidSeconds()); + return $code; + } + } + + throw new PaymentException('付款备注码已用尽,请稍后重试', 40200); + } + + /** + * 对同一通道的识别信息分配加锁。 + * + * 防止并发发起支付时分配到相同金额或相同备注码。 + * + * @param callable $callback 回调 + * @return mixed + */ + private function withChannelLock(callable $callback): mixed + { + $key = 'mpay_personal_receipt_lock_' . (int) $this->getConfig('channel_id'); + $token = bin2hex(random_bytes(8)); + + for ($i = 0; $i < 20; $i++) { + if (!Cache::has($key)) { + Cache::set($key, $token, 10); + } + + if ((string) Cache::get($key) === $token) { + try { + return $callback(); + } finally { + if ((string) Cache::get($key) === $token) { + Cache::delete($key); + } + } + } + usleep(50000); + } + + throw new PaymentException('当前通道正在分配收款标识,请稍后重试', 40200); + } + + /** + * 计算本次个人收款识别的过期时间。 + * + * @return string 过期时间 + */ + private function expireAt(): string + { + return date('Y-m-d H:i:s', time() + $this->receiptValidSeconds()); + } + + /** + * 校验并读取 SmsForwarder 载荷。 + * + * 签名规则按 SmsForwarder 文档:使用 timestamp、密钥和 HMAC-SHA256 校验 sign。 + * + * @param Request $request 请求对象 + * @return array + */ + private function verifiedSmsForwarderPayload(Request $request): array + { + $payload = $this->requestPayload($request); + $timestamp = trim((string) ($payload['timestamp'] ?? '')); + $sign = trim((string) ($payload['sign'] ?? '')); + $secret = (string) $this->getConfig('sms_forwarder_secret', ''); + if ($timestamp === '' || $sign === '' || $secret === '') { + throw new PaymentException('SmsForwarder 通知签名参数不完整', 40200); + } + + $timestampSeconds = (int) floor(((int) $timestamp) / 1000); + $tolerance = max(30, (int) $this->getConfig('sms_forwarder_time_tolerance', 300)); + if ($timestampSeconds <= 0 || abs(time() - $timestampSeconds) > $tolerance) { + throw new PaymentException('SmsForwarder 通知时间已失效', 40200); + } + + $expected = base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)); + if (!hash_equals($expected, rawurldecode($sign))) { + throw new PaymentException('SmsForwarder 通知签名校验失败', 40200); + } + + if (trim((string) ($payload['content'] ?? '')) === '') { + throw new PaymentException('SmsForwarder 通知内容为空', 40200); + } + + return $payload; + } + + /** + * 读取请求载荷。 + * + * Webman Request 已统一处理 query、form 和 JSON 请求体,这里直接使用 all()。 + * + * @param Request $request 请求对象 + * @return array + */ + private function requestPayload(Request $request): array + { + return (array) $request->all(); + } + + /** + * 金额模式下通过通知金额定位唯一支付单。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNoByAmount(array $payload): string + { + $amount = $this->amountFromContent((string) $payload['content']); + $orders = $this->payOrderRepository->listMutableReceiptOrdersByAmount( + [(int) $this->getConfig('channel_id')], + $amount, + 0, + date('Y-m-d H:i:s'), + ['pay_no'] + ); + + if ($orders->count() !== 1) { + throw new PaymentException('金额通知未匹配到唯一支付单', 40200, [ + 'amount' => FormatHelper::amount($amount), + 'matched_count' => $orders->count(), + ]); + } + + return (string) $orders->first()->pay_no; + } + + /** + * 备注模式下通过缓存中的 4 位备注码定位支付单。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNoByRemark(array $payload): string + { + $remarkCode = $this->remarkFromContent((string) $payload['content']); + $payNo = (string) Cache::get($this->remarkCacheKey($remarkCode), ''); + if ($payNo === '') { + throw new PaymentException('付款备注已失效或不存在', 40200, ['remark_code' => $remarkCode]); + } + + return $payNo; + } + + /** + * 根据配置选择金额匹配或备注匹配。 + * + * @param array $payload 通知载荷 + * @return string 支付单号 + */ + private function locatePayNo(array $payload): string + { + return $this->receiptMatchMode() === 'remark' + ? $this->locatePayNoByRemark($payload) + : $this->locatePayNoByAmount($payload); + } + + /** + * 从通知文本中提取收款金额。 + * + * @param string $content 通知内容 + * @return int 金额,单位分 + */ + private function amountFromContent(string $content): int + { + if (preg_match('/(?:收款|到账|收钱|付款|支付|转账)[^\d]{0,20}(\d+(?:\.\d{1,2})?)\s*元/u', $content, $matches) !== 1 + && preg_match('/(?moneyToCents((string) $matches[1]); + } + + /** + * 从通知文本中提取 4 位付款备注码。 + * + * @param string $content 通知内容 + * @return string 备注码 + */ + private function remarkFromContent(string $content): string + { + if (preg_match('/(?:备注|留言|附言|付款备注|收款备注)[::\s]*([0-9]{4})/u', $content, $matches) !== 1) { + throw new PaymentException('通知内容未识别到付款备注', 40200); + } + + return (string) $matches[1]; + } + + /** + * 将金额文本转换为分。 + * + * @param string $money 金额文本 + * @return int 金额,单位分 + */ + private function moneyToCents(string $money): int + { + if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + throw new PaymentException('通知金额格式不合法', 40200, ['money' => $money]); + } + + [$integer, $fraction] = array_pad(explode('.', $money, 2), 2, ''); + return (int) $integer * 100 + (int) str_pad(substr($fraction, 0, 2), 2, '0'); + } + + /** + * 生成备注码缓存键。 + * + * @param string $code 备注码 + * @return string 缓存键 + */ + private function remarkCacheKey(string $code): string + { + return 'mpay_personal_receipt_remark_' . (int) $this->getConfig('channel_id') . '_' . $code; + } + + /** + * 为 SmsForwarder 通知生成稳定的渠道交易号。 + * + * @param array $payload 通知载荷 + * @return string 渠道交易号 + */ + private function channelTradeNo(array $payload): string + { + return 'SF' . substr(hash('sha256', (string) ($payload['from'] ?? '') . '|' . (string) $payload['timestamp'] . '|' . (string) $payload['content']), 0, 30); + } + + /** + * 从 SmsForwarder 毫秒时间戳提取支付时间。 + * + * @param array $payload 通知载荷 + * @return string|null 支付时间 + */ + private function paidAtFromPayload(array $payload): ?string + { + $timestamp = (int) ($payload['timestamp'] ?? 0); + return $timestamp > 0 ? date('Y-m-d H:i:s', (int) floor($timestamp / 1000)) : null; + } + +} diff --git a/app/http/admin/controller/merchant/MerchantController.php b/app/http/admin/controller/merchant/MerchantController.php index c4b4933..f9447e9 100644 --- a/app/http/admin/controller/merchant/MerchantController.php +++ b/app/http/admin/controller/merchant/MerchantController.php @@ -5,6 +5,7 @@ namespace app\http\admin\controller\merchant; use app\common\base\BaseController; use app\http\admin\validation\MerchantApiCredentialValidator; use app\http\admin\validation\MerchantValidator; +use app\service\merchant\auth\MerchantAuthService; use app\service\merchant\MerchantService; use support\Request; use support\Response; @@ -25,7 +26,8 @@ class MerchantController extends BaseController * @return void */ public function __construct( - protected MerchantService $merchantService + protected MerchantService $merchantService, + protected MerchantAuthService $merchantAuthService ) { } @@ -127,6 +129,34 @@ class MerchantController extends BaseController return $this->success($this->merchantService->resetPassword((int) $data['id'], (string) $data['password'])); } + /** + * 签发商户后台临时登录令牌。 + * + * 该入口只在管理后台登录态下可用,用于客服或运营从商户工作台直接进入当前商户后台。 + * + * @param Request $request 请求对象 + * @param string $id 商户ID + * @return Response 响应对象 + */ + public function loginToken(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], MerchantValidator::class, 'loginToken'); + $merchant = $this->merchantService->ensureMerchantEnabled((int) $data['id']); + $issued = $this->merchantAuthService->issueToken( + (int) $merchant->id, + 3600, + (string) $request->getRealIp(), + (string) $request->header('user-agent', '') + ); + + return $this->success([ + 'token' => (string) $issued['token'], + 'expires_in' => (int) $issued['expires_in'], + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + ]); + } + /** * 生成或重置商户 API 凭证。 * diff --git a/app/http/admin/controller/payment/PaymentChannelController.php b/app/http/admin/controller/payment/PaymentChannelController.php index 6a4dc3a..29a1121 100644 --- a/app/http/admin/controller/payment/PaymentChannelController.php +++ b/app/http/admin/controller/payment/PaymentChannelController.php @@ -5,6 +5,7 @@ namespace app\http\admin\controller\payment; use app\common\base\BaseController; use app\http\admin\validation\PaymentChannelValidator; use app\service\payment\config\PaymentChannelService; +use app\service\payment\config\PaymentChannelTestService; use support\Request; use support\Response; @@ -14,6 +15,7 @@ use support\Response; * 负责支付通道的列表、详情、新增、修改和删除。 * * @property PaymentChannelService $paymentChannelService 支付渠道服务 + * @property PaymentChannelTestService $paymentChannelTestService 支付通道测试服务 */ class PaymentChannelController extends BaseController { @@ -21,10 +23,12 @@ class PaymentChannelController extends BaseController * 构造方法。 * * @param PaymentChannelService $paymentChannelService 支付渠道服务 + * @param PaymentChannelTestService $paymentChannelTestService 支付通道测试服务 * @return void */ public function __construct( - protected PaymentChannelService $paymentChannelService + protected PaymentChannelService $paymentChannelService, + protected PaymentChannelTestService $paymentChannelTestService ) { } @@ -157,6 +161,25 @@ class PaymentChannelController extends BaseController return $this->success($this->paymentChannelService->searchOptions($request->all(), $page, $pageSize)); } + + /** + * 发起当前通道测试支付。 + * + * @param Request $request 请求对象 + * @param string $id 支付渠道ID + * @return Response 响应对象 + */ + public function test(Request $request, string $id): Response + { + $data = $this->validated( + array_merge($request->all(), ['id' => (int) $id]), + PaymentChannelValidator::class, + 'test' + ); + $data['client_ip'] = (string) $request->getRealIp(); + + return $this->success($this->paymentChannelTestService->submit((int) $data['id'], $data)); + } } diff --git a/app/http/admin/controller/system/SystemConfigPageController.php b/app/http/admin/controller/system/SystemConfigPageController.php index 4f0f561..5ff6bb6 100644 --- a/app/http/admin/controller/system/SystemConfigPageController.php +++ b/app/http/admin/controller/system/SystemConfigPageController.php @@ -16,7 +16,7 @@ use support\Response; class SystemConfigPageController extends BaseController { /** - * 构造方法。 + * 构造方法。 * * @param SystemConfigPageService $systemConfigPageService 系统配置页面服务 * @return void diff --git a/app/http/admin/controller/system/SystemController.php b/app/http/admin/controller/system/SystemController.php index 1becced..b1d0a5a 100644 --- a/app/http/admin/controller/system/SystemController.php +++ b/app/http/admin/controller/system/SystemController.php @@ -4,6 +4,7 @@ namespace app\http\admin\controller\system; use app\common\base\BaseController; use app\service\bootstrap\SystemBootstrapService; +use app\service\system\config\SystemPublicConfigService; use support\Request; use support\Response; @@ -11,17 +12,20 @@ use support\Response; * 管理后台系统数据控制器。 * * @property SystemBootstrapService $systemBootstrapService 系统引导服务 + * @property SystemPublicConfigService $systemPublicConfigService 系统公开配置服务 */ class SystemController extends BaseController { /** - * 构造方法。 + * 构造方法。 * * @param SystemBootstrapService $systemBootstrapService 系统引导服务 + * @param SystemPublicConfigService $systemPublicConfigService 系统公开配置服务 * @return void */ public function __construct( - protected SystemBootstrapService $systemBootstrapService + protected SystemBootstrapService $systemBootstrapService, + protected SystemPublicConfigService $systemPublicConfigService ) { } @@ -46,6 +50,17 @@ class SystemController extends BaseController { return $this->success($this->systemBootstrapService->getDictItems((string) $request->get('code', ''))); } + + /** + * 获取管理后台公开展示配置。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function publicConfig(Request $request): Response + { + return $this->success($this->systemPublicConfigService->adminPortal()); + } } diff --git a/app/http/admin/controller/trade/PayOrderController.php b/app/http/admin/controller/trade/PayOrderController.php index 1078a95..2acd617 100644 --- a/app/http/admin/controller/trade/PayOrderController.php +++ b/app/http/admin/controller/trade/PayOrderController.php @@ -3,7 +3,9 @@ namespace app\http\admin\controller\trade; use app\common\base\BaseController; +use app\http\admin\validation\PayOrderActionValidator; use app\http\admin\validation\PayOrderValidator; +use app\service\payment\order\PayOrderAdminActionService; use app\service\payment\order\PayOrderService; use support\Request; use support\Response; @@ -14,6 +16,7 @@ use support\Response; * 当前提供列表查询和详情查看,便于后台直接排查支付链路。 * * @property PayOrderService $payOrderService 支付订单服务 + * @property PayOrderAdminActionService $payOrderAdminActionService 支付订单后台操作服务 */ class PayOrderController extends BaseController { @@ -21,10 +24,12 @@ class PayOrderController extends BaseController * 构造方法。 * * @param PayOrderService $payOrderService 支付订单服务 + * @param PayOrderAdminActionService $payOrderAdminActionService 支付订单后台操作服务 * @return void */ public function __construct( - protected PayOrderService $payOrderService + protected PayOrderService $payOrderService, + protected PayOrderAdminActionService $payOrderAdminActionService ) { } @@ -40,7 +45,7 @@ class PayOrderController extends BaseController $page = max(1, (int) ($data['page'] ?? 1)); $pageSize = max(1, (int) ($data['page_size'] ?? 10)); - return $this->success($this->payOrderService->paginate($data, $page, $pageSize)); + return $this->success($this->payOrderService->paginate($data, $page, $pageSize, null, true)); } /** @@ -58,7 +63,151 @@ class PayOrderController extends BaseController 'show' ); - return $this->success($this->payOrderService->detail($payNo)); + return $this->success($this->payOrderService->detail($payNo, null, true)); + } + + /** + * 查询支付订单可操作项。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function actions(Request $request, string $payNo): Response + { + $this->validated( + array_merge($request->all(), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'actions' + ); + + return $this->success($this->payOrderAdminActionService->actions($payNo)); + } + + /** + * 重新通知商户。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function renotify(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'renotify' + ); + + return $this->success($this->payOrderAdminActionService->renotify($payNo, $data, $this->currentAdminId($request))); + } + + /** + * 主动查询上游支付结果。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function activeQuery(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'active_query' + ); + + return $this->success($this->payOrderAdminActionService->activeQuery($payNo, $data, $this->currentAdminId($request))); + } + + /** + * 发起 API 退款。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function apiRefund(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'api_refund' + ); + + return $this->success($this->payOrderAdminActionService->apiRefund($payNo, $data, $this->currentAdminId($request))); + } + + /** + * 手动退款。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function manualRefund(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'manual_refund' + ); + + return $this->success($this->payOrderAdminActionService->manualRefund($payNo, $data, $this->currentAdminId($request))); + } + + /** + * 手动补单。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function manualSuccess(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'manual_success' + ); + + return $this->success($this->payOrderAdminActionService->manualSuccess($payNo, $data, $this->currentAdminId($request))); + } + + /** + * 冻结支付订单。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function freeze(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'freeze' + ); + + return $this->success($this->payOrderAdminActionService->freeze($payNo, $data, $this->currentAdminId($request))); + } + + /** + * 解冻支付订单。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function unfreeze(Request $request, string $payNo): Response + { + $data = $this->validated( + array_merge($this->payload($request), ['pay_no' => $payNo]), + PayOrderActionValidator::class, + 'unfreeze' + ); + + return $this->success($this->payOrderAdminActionService->unfreeze($payNo, $data, $this->currentAdminId($request))); } } diff --git a/app/http/admin/controller/trade/SettlementOrderController.php b/app/http/admin/controller/trade/SettlementOrderController.php index d462155..58b663d 100644 --- a/app/http/admin/controller/trade/SettlementOrderController.php +++ b/app/http/admin/controller/trade/SettlementOrderController.php @@ -3,9 +3,11 @@ namespace app\http\admin\controller\trade; use app\common\base\BaseController; +use app\exception\BusinessStateException; use app\exception\ResourceNotFoundException; use app\http\admin\validation\SettlementOrderValidator; use app\service\payment\settlement\SettlementOrderQueryService; +use app\service\payment\settlement\SettlementService; use support\Request; use support\Response; @@ -13,6 +15,7 @@ use support\Response; * 清算订单控制器。 * * @property SettlementOrderQueryService $settlementOrderQueryService 结算订单查询服务 + * @property SettlementService $settlementService 清算服务 */ class SettlementOrderController extends BaseController { @@ -20,10 +23,12 @@ class SettlementOrderController extends BaseController * 构造方法。 * * @param SettlementOrderQueryService $settlementOrderQueryService 结算订单查询服务 + * @param SettlementService $settlementService 清算服务 * @return void */ public function __construct( - protected SettlementOrderQueryService $settlementOrderQueryService + protected SettlementOrderQueryService $settlementOrderQueryService, + protected SettlementService $settlementService ) { } @@ -62,6 +67,53 @@ class SettlementOrderController extends BaseController return $this->fail('清算订单不存在', 404); } } + + /** + * 清算入账。 + * + * @param Request $request 请求对象 + * @param string $settleNo 清算单号 + * @return Response 响应对象 + */ + public function complete(Request $request, string $settleNo): Response + { + $data = $this->validated(['settle_no' => $settleNo], SettlementOrderValidator::class, 'show'); + + try { + return $this->success($this->settlementService->completeSettlement((string) $data['settle_no'])); + } catch (ResourceNotFoundException $e) { + return $this->fail($e->getMessage(), $e->getCode()); + } catch (BusinessStateException $e) { + return $this->fail($e->getMessage(), $e->getCode()); + } + } + + /** + * 标记清算失败。 + * + * @param Request $request 请求对象 + * @param string $settleNo 清算单号 + * @return Response 响应对象 + */ + public function markFailed(Request $request, string $settleNo): Response + { + $data = $this->validated( + array_merge($request->all(), ['settle_no' => $settleNo]), + SettlementOrderValidator::class, + 'fail' + ); + + try { + return $this->success($this->settlementService->failSettlement( + (string) $data['settle_no'], + (string) ($data['reason'] ?? '') + )); + } catch (ResourceNotFoundException $e) { + return $this->fail($e->getMessage(), $e->getCode()); + } catch (BusinessStateException $e) { + return $this->fail($e->getMessage(), $e->getCode()); + } + } } diff --git a/app/http/admin/validation/MerchantApiCredentialValidator.php b/app/http/admin/validation/MerchantApiCredentialValidator.php index c669561..d4ea04b 100644 --- a/app/http/admin/validation/MerchantApiCredentialValidator.php +++ b/app/http/admin/validation/MerchantApiCredentialValidator.php @@ -18,7 +18,6 @@ class MerchantApiCredentialValidator extends Validator 'id' => 'sometimes|integer|min:1', 'keyword' => 'sometimes|string|max:128', 'merchant_id' => 'sometimes|integer|min:1|exists:ma_merchant,id', - 'sign_type' => 'sometimes|integer|in:0,1', 'rotate_v1' => 'sometimes|integer|in:0,1', 'rotate_v2' => 'sometimes|integer|in:0,1', 'api_key' => 'nullable|string|max:128', @@ -37,7 +36,6 @@ class MerchantApiCredentialValidator extends Validator 'id' => '凭证ID', 'keyword' => '关键词', 'merchant_id' => '所属商户', - 'sign_type' => '签名类型', 'rotate_v1' => '是否重置 V1', 'rotate_v2' => '是否重置 V2', 'api_key' => '接口凭证值', @@ -54,11 +52,11 @@ class MerchantApiCredentialValidator extends Validator */ protected array $scenes = [ 'index' => ['keyword', 'merchant_id', 'status', 'page', 'page_size'], - 'store' => ['merchant_id', 'sign_type', 'api_key', 'merchant_public_key', 'status'], - 'update' => ['id', 'sign_type', 'api_key', 'merchant_public_key', 'status'], + 'store' => ['merchant_id', 'api_key', 'merchant_public_key', 'status'], + 'update' => ['id', 'api_key', 'merchant_public_key', 'status'], 'show' => ['id'], 'destroy' => ['id'], - 'issueCredential' => ['rotate_v1', 'rotate_v2', 'sign_type', 'status'], + 'issueCredential' => ['rotate_v1', 'rotate_v2', 'status'], ]; /** @@ -70,7 +68,6 @@ class MerchantApiCredentialValidator extends Validator { return $this->appendRules([ 'merchant_id' => 'required|integer|min:1|exists:ma_merchant,id', - 'sign_type' => 'required|integer|in:0,1', 'merchant_public_key' => 'nullable|string|max:65535', 'status' => 'required|integer|in:0,1', ]); @@ -85,7 +82,6 @@ class MerchantApiCredentialValidator extends Validator { return $this->appendRules([ 'id' => 'required|integer|min:1', - 'sign_type' => 'sometimes|integer|in:0,1', 'api_key' => 'nullable|string|max:128', 'merchant_public_key' => 'nullable|string|max:65535', 'status' => 'sometimes|integer|in:0,1', @@ -102,7 +98,6 @@ class MerchantApiCredentialValidator extends Validator return $this->appendRules([ 'rotate_v1' => 'sometimes|integer|in:0,1', 'rotate_v2' => 'sometimes|integer|in:0,1', - 'sign_type' => 'sometimes|integer|in:0,1', 'status' => 'sometimes|integer|in:0,1', ]); } diff --git a/app/http/admin/validation/MerchantValidator.php b/app/http/admin/validation/MerchantValidator.php index 18dba98..f125a68 100644 --- a/app/http/admin/validation/MerchantValidator.php +++ b/app/http/admin/validation/MerchantValidator.php @@ -112,6 +112,7 @@ class MerchantValidator extends Validator 'remark', ], 'updateStatus' => ['id', 'status'], + 'loginToken' => ['id'], 'resetPassword' => ['id', 'password', 'password_confirm'], 'destroy' => ['id'], ]; @@ -180,6 +181,18 @@ class MerchantValidator extends Validator ]); } + /** + * 配置商户后台登录令牌场景规则。 + * + * @return static 校验器实例 + */ + public function sceneLoginToken(): static + { + return $this->appendRules([ + 'id' => 'required|integer|min:1', + ]); + } + /** * 配置删除商户场景规则。 * @@ -192,4 +205,3 @@ class MerchantValidator extends Validator ]); } } - diff --git a/app/http/admin/validation/PayOrderActionValidator.php b/app/http/admin/validation/PayOrderActionValidator.php new file mode 100644 index 0000000..d85b64f --- /dev/null +++ b/app/http/admin/validation/PayOrderActionValidator.php @@ -0,0 +1,79 @@ + 'required|string|max:64', + 'reason' => 'sometimes|string|max:255', + 'refund_amount' => 'sometimes|integer|min:1', + 'paid_amount' => 'sometimes|integer|min:1', + 'money' => 'sometimes|string|max:32|regex:/^\d+(\.\d{1,2})?$/', + 'channel_order_no' => 'sometimes|string|max:64', + 'channel_trade_no' => 'sometimes|string|max:64', + 'paid_at' => 'sometimes|date_format:Y-m-d H:i:s', + ]; + + /** + * 字段别名。 + * + * @var array + */ + protected array $attributes = [ + 'pay_no' => '支付单号', + 'reason' => '操作原因', + 'refund_amount' => '退款金额', + 'paid_amount' => '实付金额', + 'money' => '金额', + 'channel_order_no' => '渠道订单号', + 'channel_trade_no' => '渠道交易号', + 'paid_at' => '支付时间', + ]; + + /** + * 校验场景。 + * + * @var array + */ + protected array $scenes = [ + 'actions' => ['pay_no'], + 'renotify' => ['pay_no', 'reason'], + 'active_query' => ['pay_no'], + 'api_refund' => ['pay_no'], + 'manual_refund' => ['pay_no', 'reason', 'refund_amount', 'money'], + 'manual_success' => ['pay_no', 'reason'], + 'freeze' => ['pay_no', 'reason'], + 'unfreeze' => ['pay_no', 'reason'], + ]; + + /** + * 根据场景补充动态规则。 + * + * webman/validation 只会按 scenes 截取字段规则,不会自动调用 sceneXxx 方法, + * 因此需要在 rules() 里把高风险动作的必填原因显式补上。 + * + * @return array 校验规则 + */ + public function rules(): array + { + $rules = parent::rules(); + if (in_array($this->scene(), ['manual_refund', 'manual_success', 'freeze', 'unfreeze'], true)) { + $rules['reason'] = 'required|string|max:255'; + } + + return $rules; + } +} diff --git a/app/http/admin/validation/PaymentChannelValidator.php b/app/http/admin/validation/PaymentChannelValidator.php index 9284cf8..73b71a8 100644 --- a/app/http/admin/validation/PaymentChannelValidator.php +++ b/app/http/admin/validation/PaymentChannelValidator.php @@ -34,6 +34,7 @@ class PaymentChannelValidator extends Validator 'remark' => 'nullable|string|max:255', 'status' => 'sometimes|integer|in:0,1', 'sort_no' => 'nullable|integer|min:0', + 'money' => 'sometimes|numeric|min:0.01', 'page' => 'sometimes|integer|min:1', 'page_size' => 'sometimes|integer|min:1|max:100', ]; @@ -48,8 +49,8 @@ class PaymentChannelValidator extends Validator 'keyword' => '关键字', 'merchant_id' => '所属商户', 'name' => '通道名称', - 'split_rate_bp' => '分成比例', - 'cost_rate_bp' => '通道成本', + 'split_rate_bp' => '商户分账比例', + 'cost_rate_bp' => '第三方通道成本', 'channel_mode' => '通道模式', 'pay_type_id' => '支付方式', 'plugin_code' => '支付插件', @@ -61,6 +62,7 @@ class PaymentChannelValidator extends Validator 'remark' => '备注', 'status' => '通道状态', 'sort_no' => '排序', + 'money' => '测试金额', 'page' => '页码', 'page_size' => '每页条数', ]; @@ -77,6 +79,7 @@ class PaymentChannelValidator extends Validator 'updateStatus' => ['id', 'status'], 'show' => ['id'], 'destroy' => ['id'], + 'test' => ['id', 'name', 'money'], ]; /** @@ -117,6 +120,11 @@ class PaymentChannelValidator extends Validator 'show', 'destroy' => array_merge($rules, [ 'id' => 'required|integer|min:1', ]), + 'test' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'name' => 'required|string|min:1|max:128', + 'money' => 'required|numeric|min:0.01', + ]), default => $rules, }; } diff --git a/app/http/admin/validation/SettlementOrderValidator.php b/app/http/admin/validation/SettlementOrderValidator.php index 623253b..07871f0 100644 --- a/app/http/admin/validation/SettlementOrderValidator.php +++ b/app/http/admin/validation/SettlementOrderValidator.php @@ -21,6 +21,7 @@ class SettlementOrderValidator extends Validator 'channel_id' => 'sometimes|integer|min:1', 'status' => 'sometimes|integer|min:0', 'cycle_type' => 'sometimes|integer|min:0', + 'reason' => 'sometimes|string|max:255', 'page' => 'sometimes|integer|min:1', 'page_size' => 'sometimes|integer|min:1|max:100', ]; @@ -37,6 +38,7 @@ class SettlementOrderValidator extends Validator 'channel_id' => '所属通道', 'status' => '清算单状态', 'cycle_type' => '结算周期类型', + 'reason' => '失败原因', 'page' => '页码', 'page_size' => '每页条数', ]; @@ -49,6 +51,6 @@ class SettlementOrderValidator extends Validator protected array $scenes = [ 'index' => ['keyword', 'merchant_id', 'channel_id', 'status', 'cycle_type', 'page', 'page_size'], 'show' => ['settle_no'], + 'fail' => ['settle_no', 'reason'], ]; } - diff --git a/app/http/api/controller/cashier/CashierController.php b/app/http/api/controller/cashier/CashierController.php index 3293653..63cec40 100644 --- a/app/http/api/controller/cashier/CashierController.php +++ b/app/http/api/controller/cashier/CashierController.php @@ -64,4 +64,19 @@ class CashierController extends BaseController $this->cashierService->payOrderDetail((string) ($payload['pay_no'] ?? '')) ); } + + /** + * 查询支付单状态。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function payOrderStatus(Request $request): Response + { + $payload = $this->validated($request->all(), CashierValidator::class, 'pay_order_status'); + + return $this->success( + $this->cashierService->payOrderStatus((string) ($payload['pay_no'] ?? '')) + ); + } } diff --git a/app/http/api/controller/epay/EpayV2Controller.php b/app/http/api/controller/epay/EpayV2Controller.php index 3ebef68..c642f9b 100644 --- a/app/http/api/controller/epay/EpayV2Controller.php +++ b/app/http/api/controller/epay/EpayV2Controller.php @@ -170,4 +170,16 @@ class EpayV2Controller extends BaseController { return $this->payOrderService->handlePluginCallback($payNo, $request); } + + /** + * 通道级通知入口。 + * + * @param Request $request 请求对象 + * @param int $chanId 通道ID + * @return string|Response + */ + public function channelNotify(Request $request, int $chanId): string|Response + { + return $this->payOrderService->handleChannelNotify($chanId, $request); + } } diff --git a/app/http/api/controller/system/SystemPublicConfigController.php b/app/http/api/controller/system/SystemPublicConfigController.php new file mode 100644 index 0000000..2a6b4db --- /dev/null +++ b/app/http/api/controller/system/SystemPublicConfigController.php @@ -0,0 +1,35 @@ +success($this->systemPublicConfigService->cashier()); + } +} diff --git a/app/http/api/validation/CashierValidator.php b/app/http/api/validation/CashierValidator.php index 11c4150..ce4080b 100644 --- a/app/http/api/validation/CashierValidator.php +++ b/app/http/api/validation/CashierValidator.php @@ -27,6 +27,7 @@ class CashierValidator extends Validator 'context' => ['biz_no'], 'confirm' => ['biz_no', 'type'], 'pay_order' => ['pay_no'], + 'pay_order_status' => ['pay_no'], ]; /** @@ -65,4 +66,16 @@ class CashierValidator extends Validator 'pay_no' => 'required|string|max:32', ]); } + + /** + * 支付状态查询场景。 + * + * @return static + */ + public function scenePayOrderStatus(): static + { + return $this->appendRules([ + 'pay_no' => 'required|string|max:32', + ]); + } } diff --git a/app/http/api/validation/EpayV1Validator.php b/app/http/api/validation/EpayV1Validator.php index 767df33..7819e1a 100644 --- a/app/http/api/validation/EpayV1Validator.php +++ b/app/http/api/validation/EpayV1Validator.php @@ -21,7 +21,7 @@ class EpayV1Validator extends Validator 'notify_url' => 'nullable|string|max:255', 'return_url' => 'nullable|string|max:255', 'name' => 'nullable|string|max:255', - 'money' => 'nullable|regex:/^\d+(?:\.\d{1,2})?$/', + 'money' => 'nullable|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', 'param' => 'nullable', 'clientip' => 'nullable|ip', 'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay,jump', @@ -73,7 +73,7 @@ class EpayV1Validator extends Validator 'notify_url' => 'required|string|max:255', 'return_url' => 'required|string|max:255', 'name' => 'required|string|max:255', - 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', + 'money' => 'required|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', 'sign_type' => 'required|string|in:MD5', 'sign' => 'required|string|max:255', ]); @@ -92,7 +92,7 @@ class EpayV1Validator extends Validator 'notify_url' => 'required|string|max:255', 'return_url' => 'nullable|string|max:255', 'name' => 'required|string|max:255', - 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', + 'money' => 'required|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', 'clientip' => 'required|ip', 'sign_type' => 'required|string|in:MD5', 'sign' => 'required|string|max:255', @@ -160,7 +160,7 @@ class EpayV1Validator extends Validator { return $this->appendRules([ 'key' => 'required|string|max:128', - 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', + 'money' => 'required|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', 'trade_no' => 'nullable|string|max:64|required_without:out_trade_no', 'out_trade_no' => 'nullable|string|max:64|required_without:trade_no', ]); diff --git a/app/http/api/validation/EpayV2Validator.php b/app/http/api/validation/EpayV2Validator.php index f40691c..94491f2 100644 --- a/app/http/api/validation/EpayV2Validator.php +++ b/app/http/api/validation/EpayV2Validator.php @@ -14,8 +14,7 @@ class EpayV2Validator extends Validator protected array $rules = [ 'pid' => 'required|integer|min:1', 'timestamp' => 'required|integer|min:1', - // 兼容旧版 SDK 里使用的 `RSA` 简写,同时内部统一按 SHA256WithRSA 验签。 - 'sign_type' => 'required|string|in:SHA256WithRSA,RSA', + 'sign_type' => 'required|string|in:RSA', // RSA 签名是 Base64 文本,长度会明显超过 MD5,不能沿用 255 的短限制。 'sign' => 'required|string|max:2048', 'type' => 'nullable|string|max:32', @@ -25,7 +24,7 @@ class EpayV2Validator extends Validator 'notify_url' => 'nullable|string|max:255', 'return_url' => 'nullable|string|max:255', 'name' => 'nullable|string|max:255', - 'money' => 'nullable|regex:/^\d+(?:\.\d{1,2})?$/', + 'money' => 'nullable|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', 'param' => 'nullable', 'auth_code' => 'nullable|string|max:128', 'sub_openid' => 'nullable|string|max:128', @@ -104,8 +103,8 @@ class EpayV2Validator extends Validator 'notify_url' => 'required|string|max:255', 'return_url' => 'required|string|max:255', 'name' => 'required|string|max:255', - 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', - 'sign_type' => 'required|string|in:SHA256WithRSA,RSA', + 'money' => 'required|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', + 'sign_type' => 'required|string|in:RSA', 'sign' => 'required|string|max:2048', ]); } @@ -124,9 +123,9 @@ class EpayV2Validator extends Validator 'notify_url' => 'required|string|max:255', 'return_url' => 'nullable|string|max:255', 'name' => 'required|string|max:255', - 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', + 'money' => 'required|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', 'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay', - 'sign_type' => 'required|string|in:SHA256WithRSA,RSA', + 'sign_type' => 'required|string|in:RSA', 'sign' => 'required|string|max:2048', ]); } @@ -139,11 +138,11 @@ class EpayV2Validator extends Validator public function sceneRefund(): static { return $this->appendRules([ - 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', + 'money' => 'required|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', 'trade_no' => 'nullable|string|max:64|required_without:out_trade_no', 'out_trade_no' => 'nullable|string|max:64|required_without:trade_no', 'out_refund_no' => 'nullable|string|max:64', - 'sign_type' => 'required|string|in:SHA256WithRSA,RSA', + 'sign_type' => 'required|string|in:RSA', 'sign' => 'required|string|max:2048', ]); } @@ -208,8 +207,8 @@ class EpayV2Validator extends Validator 'type' => 'required|string|in:alipay,wxpay,qqpay,bank', 'account' => 'required|string|max:100', 'name' => 'required|string|max:100', - 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', - 'sign_type' => 'required|string|in:SHA256WithRSA,RSA', + 'money' => 'required|regex:/^(?=.*[1-9])\d+(?:\.\d{1,2})?$/', + 'sign_type' => 'required|string|in:RSA', 'sign' => 'required|string|max:2048', ]); } diff --git a/app/http/mer/controller/system/SystemController.php b/app/http/mer/controller/system/SystemController.php index 7afe310..db1fcaf 100644 --- a/app/http/mer/controller/system/SystemController.php +++ b/app/http/mer/controller/system/SystemController.php @@ -4,6 +4,7 @@ namespace app\http\mer\controller\system; use app\common\base\BaseController; use app\service\bootstrap\SystemBootstrapService; +use app\service\system\config\SystemPublicConfigService; use support\Request; use support\Response; @@ -11,17 +12,20 @@ use support\Response; * 商户后台系统数据控制器。 * * @property SystemBootstrapService $systemBootstrapService 系统引导服务 + * @property SystemPublicConfigService $systemPublicConfigService 系统公开配置服务 */ class SystemController extends BaseController { /** - * 构造方法。 + * 构造方法。 * * @param SystemBootstrapService $systemBootstrapService 系统引导服务 + * @param SystemPublicConfigService $systemPublicConfigService 系统公开配置服务 * @return void */ public function __construct( - protected SystemBootstrapService $systemBootstrapService + protected SystemBootstrapService $systemBootstrapService, + protected SystemPublicConfigService $systemPublicConfigService ) { } @@ -46,6 +50,17 @@ class SystemController extends BaseController { return $this->success($this->systemBootstrapService->getDictItems((string) $request->get('code', ''))); } + + /** + * 获取商户后台公开展示配置。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function publicConfig(Request $request): Response + { + return $this->success($this->systemPublicConfigService->merchantPortal()); + } } diff --git a/app/http/mer/validation/MerchantPortalValidator.php b/app/http/mer/validation/MerchantPortalValidator.php index 0a8a475..b5d4e89 100644 --- a/app/http/mer/validation/MerchantPortalValidator.php +++ b/app/http/mer/validation/MerchantPortalValidator.php @@ -45,7 +45,6 @@ class MerchantPortalValidator extends Validator 'status' => 'sometimes|integer|in:0,1', 'rotate_v1' => 'sometimes|integer|in:0,1', 'rotate_v2' => 'sometimes|integer|in:0,1', - 'sign_type' => 'sometimes|integer|in:0,1', 'sort_no' => 'nullable|integer|min:0', 'page' => 'sometimes|integer|min:1', 'page_size' => 'sometimes|integer|min:1|max:100', @@ -87,7 +86,6 @@ class MerchantPortalValidator extends Validator 'status' => '状态', 'rotate_v1' => 'V1 凭证', 'rotate_v2' => 'V2 凭证', - 'sign_type' => '签名类型', 'sort_no' => '排序', 'page' => '页码', 'page_size' => '每页条数', @@ -118,7 +116,7 @@ class MerchantPortalValidator extends Validator 'channelStore' => ['name', 'pay_type_id', 'plugin_code', 'api_config_id', 'daily_limit_amount', 'daily_limit_count', 'min_amount', 'max_amount', 'remark', 'status', 'sort_no'], 'channelUpdate' => ['id', 'name', 'pay_type_id', 'plugin_code', 'api_config_id', 'daily_limit_amount', 'daily_limit_count', 'min_amount', 'max_amount', 'remark', 'status', 'sort_no'], 'channelDestroy' => ['id'], - 'issueCredential' => ['rotate_v1', 'rotate_v2', 'sign_type', 'status'], + 'issueCredential' => ['rotate_v1', 'rotate_v2', 'status'], ]; public function rules(): array diff --git a/app/listener/PaymentChannelStatListener.php b/app/listener/PaymentChannelStatListener.php new file mode 100644 index 0000000..738be5f --- /dev/null +++ b/app/listener/PaymentChannelStatListener.php @@ -0,0 +1,130 @@ + $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onPayOrderSucceeded(array $payload = [], string $eventName = ''): void + { + $payOrder = $this->resolvePayOrder($payload); + if (!$payOrder) { + return; + } + + $this->guardedRecord(fn () => $this->channelDailyStatService->recordPaySuccess($payOrder), $eventName, (string) $payOrder->pay_no); + } + + /** + * 记录支付失败、关闭或超时统计。 + * + * @param array $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onPayOrderFailed(array $payload = [], string $eventName = ''): void + { + $payOrder = $this->resolvePayOrder($payload); + if (!$payOrder) { + return; + } + + $this->guardedRecord(fn () => $this->channelDailyStatService->recordPayFailure($payOrder), $eventName, (string) $payOrder->pay_no); + } + + /** + * 记录退款成功统计。 + * + * @param array $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onRefundOrderSucceeded(array $payload = [], string $eventName = ''): void + { + $refundOrder = $payload['refund_order'] ?? null; + if (!$refundOrder instanceof RefundOrder) { + $refundNo = trim((string) ($payload['refund_no'] ?? '')); + $refundOrder = $refundNo !== '' ? $this->refundOrderRepository->findByRefundNo($refundNo) : null; + } + + if (!$refundOrder instanceof RefundOrder) { + return; + } + + $this->guardedRecord(fn () => $this->channelDailyStatService->recordRefundSuccess($refundOrder), $eventName, (string) $refundOrder->refund_no); + } + + /** + * 从事件载荷中解析支付单。 + * + * @param array $payload 事件载荷 + * @return PayOrder|null 支付单 + */ + private function resolvePayOrder(array $payload): ?PayOrder + { + $payOrder = $payload['pay_order'] ?? null; + if ($payOrder instanceof PayOrder) { + return $payOrder; + } + + $payNo = trim((string) ($payload['pay_no'] ?? '')); + return $payNo !== '' ? $this->payOrderRepository->findByPayNo($payNo) : null; + } + + /** + * 执行统计写入并吞掉监听器内部异常。 + * + * 统计失败不应影响支付主链路,所以这里只记录日志,后续可通过补偿任务重算。 + * + * @param callable $callback 统计写入回调 + * @param string $eventName 事件名称 + * @param string $refNo 关联单号 + * @return void + */ + private function guardedRecord(callable $callback, string $eventName, string $refNo): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning(sprintf( + '[PaymentChannelStatListener] 统计更新失败 event=%s ref_no=%s error=%s', + $eventName, + $refNo, + $e->getMessage() + )); + } + } +} diff --git a/app/listener/PaymentMerchantNotifyListener.php b/app/listener/PaymentMerchantNotifyListener.php index 76ea36b..177cdf1 100644 --- a/app/listener/PaymentMerchantNotifyListener.php +++ b/app/listener/PaymentMerchantNotifyListener.php @@ -9,25 +9,37 @@ use app\repository\payment\settlement\SettlementOrderRepository; use app\repository\payment\trade\PayOrderRepository; use app\repository\payment\trade\RefundOrderRepository; use app\service\payment\runtime\MerchantNotifyDispatcherService; +use app\service\payment\runtime\PaymentQueueService; use support\Log; /** * 支付域商户通知监听器。 * - * 聚合支付、退款、清算等会触发商户通知的事件处理。 + * 聚合支付、退款、清算等会触发商户通知的事件处理,并把实际 HTTP 通知交给队列消费。 */ class PaymentMerchantNotifyListener { + /** + * 构造方法。 + * + * @param MerchantNotifyDispatcherService $merchantNotifyDispatcherService 商户通知派发服务 + * @param PayOrderRepository $payOrderRepository 支付单仓库 + * @param RefundOrderRepository $refundOrderRepository 退款单仓库 + * @param SettlementOrderRepository $settlementOrderRepository 清算单仓库 + * @param PaymentQueueService $paymentQueueService 支付队列服务 + * @return void + */ public function __construct( protected MerchantNotifyDispatcherService $merchantNotifyDispatcherService, protected PayOrderRepository $payOrderRepository, protected RefundOrderRepository $refundOrderRepository, - protected SettlementOrderRepository $settlementOrderRepository + protected SettlementOrderRepository $settlementOrderRepository, + protected PaymentQueueService $paymentQueueService ) { } /** - * 支付成功后创建并尝试派发商户通知。 + * 支付成功后创建商户通知任务并投递队列。 * * @param array $payload 事件载荷 * @param string $eventName 事件名称 @@ -47,7 +59,10 @@ class PaymentMerchantNotifyListener return; } - $this->merchantNotifyDispatcherService->enqueueAndDispatchPaySuccess($payOrder); + $task = $this->merchantNotifyDispatcherService->enqueuePaySuccess($payOrder); + if ($task) { + $this->paymentQueueService->sendMerchantNotify((string) $task->notify_no); + } } catch (\Throwable $e) { Log::warning(sprintf( '[PaymentMerchantNotifyListener] 商户支付通知创建失败 event=%s pay_no=%s error=%s', @@ -59,7 +74,7 @@ class PaymentMerchantNotifyListener } /** - * 退款成功后创建并尝试派发商户通知。 + * 退款成功后创建商户通知任务并投递队列。 * * @param array $payload 事件载荷 * @param string $eventName 事件名称 @@ -79,7 +94,10 @@ class PaymentMerchantNotifyListener return; } - $this->merchantNotifyDispatcherService->enqueueAndDispatchRefundSuccess($refundOrder); + $task = $this->merchantNotifyDispatcherService->enqueueRefundSuccess($refundOrder); + if ($task) { + $this->paymentQueueService->sendMerchantNotify((string) $task->notify_no); + } } catch (\Throwable $e) { Log::warning(sprintf( '[PaymentMerchantNotifyListener] 商户退款通知创建失败 event=%s refund_no=%s error=%s', @@ -91,7 +109,7 @@ class PaymentMerchantNotifyListener } /** - * 清算成功后创建并尝试派发商户通知。 + * 清算成功后创建商户通知任务并投递队列。 * * @param array $payload 事件载荷 * @param string $eventName 事件名称 @@ -111,7 +129,10 @@ class PaymentMerchantNotifyListener return; } - $this->merchantNotifyDispatcherService->enqueueAndDispatchSettlementSuccess($settlementOrder); + $task = $this->merchantNotifyDispatcherService->enqueueSettlementSuccess($settlementOrder); + if ($task) { + $this->paymentQueueService->sendMerchantNotify((string) $task->notify_no); + } } catch (\Throwable $e) { Log::warning(sprintf( '[PaymentMerchantNotifyListener] 商户清算通知创建失败 event=%s settle_no=%s error=%s', diff --git a/app/listener/PaymentSettlementListener.php b/app/listener/PaymentSettlementListener.php new file mode 100644 index 0000000..acbcb18 --- /dev/null +++ b/app/listener/PaymentSettlementListener.php @@ -0,0 +1,67 @@ + $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onPayOrderSucceeded(array $payload = [], string $eventName = ''): void + { + try { + $payOrder = $payload['pay_order'] ?? null; + if (!$payOrder instanceof PayOrder) { + $payNo = trim((string) ($payload['pay_no'] ?? '')); + $payOrder = $payNo !== '' ? $this->payOrderRepository->findByPayNo($payNo) : null; + } + + if (!$payOrder instanceof PayOrder) { + Log::warning('[PaymentSettlementListener] 支付成功事件缺少可用支付单'); + return; + } + + $settlementOrder = $this->settlementAutomationService->createForPaidPayOrder($payOrder); + if ($settlementOrder && $this->settlementAutomationService->shouldAutoComplete($settlementOrder)) { + $this->paymentQueueService->sendSettlementComplete((string) $settlementOrder->settle_no); + } + } catch (\Throwable $e) { + Log::warning(sprintf( + '[PaymentSettlementListener] 清算生成失败 event=%s pay_no=%s error=%s', + $eventName, + (string) ($payload['pay_no'] ?? ''), + $e->getMessage() + )); + } + } +} diff --git a/app/listener/ReceiptWatcherListener.php b/app/listener/ReceiptWatcherListener.php new file mode 100644 index 0000000..24deb0a --- /dev/null +++ b/app/listener/ReceiptWatcherListener.php @@ -0,0 +1,69 @@ + $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onConfigChanged(array $payload = [], string $eventName = ''): void + { + try { + $this->receiptWatcherService->refreshChannelCache(); + } catch (\Throwable $e) { + Log::warning(sprintf( + '[ReceiptWatcherListener] 刷新网页流水监听缓存失败 event=%s error=%s', + $eventName, + $e->getMessage() + )); + } + } + + /** + * 支付单进入终态后清理账号查询任务。 + * + * @param array $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onPayOrderTerminated(array $payload = [], string $eventName = ''): void + { + try { + $payOrder = $payload['pay_order'] ?? null; + if (!$payOrder instanceof PayOrder) { + return; + } + + $this->receiptWatcherService->cleanupPayOrder($payOrder); + } catch (\Throwable $e) { + Log::warning(sprintf( + '[ReceiptWatcherListener] 清理网页流水监听任务失败 event=%s pay_no=%s error=%s', + $eventName, + (string) ($payload['pay_no'] ?? ''), + $e->getMessage() + )); + } + } +} diff --git a/app/model/merchant/MerchantAccountLedger.php b/app/model/merchant/MerchantAccountLedger.php index ca78721..a81a456 100644 --- a/app/model/merchant/MerchantAccountLedger.php +++ b/app/model/merchant/MerchantAccountLedger.php @@ -44,7 +44,6 @@ class MerchantAccountLedger extends BaseModel 'frozen_after', 'idempotency_key', 'remark', - 'ext_json', ]; /** @@ -62,11 +61,9 @@ class MerchantAccountLedger extends BaseModel 'available_after' => 'integer', 'frozen_before' => 'integer', 'frozen_after' => 'integer', - 'ext_json' => 'array', 'created_at' => 'datetime', ]; } - diff --git a/app/model/merchant/MerchantApiCredential.php b/app/model/merchant/MerchantApiCredential.php index 7154586..cdd4372 100644 --- a/app/model/merchant/MerchantApiCredential.php +++ b/app/model/merchant/MerchantApiCredential.php @@ -6,7 +6,7 @@ use app\common\base\BaseModel; /** * 商户对外接口凭证模型。 - * 保存商户 API 凭证、商户公钥、签名类型、启用状态和最近使用时间。 + * 保存商户 API 凭证、商户公钥、启用状态和最近使用时间。 */ class MerchantApiCredential extends BaseModel { @@ -24,7 +24,6 @@ class MerchantApiCredential extends BaseModel */ protected $fillable = [ 'merchant_id', - 'sign_type', 'api_key', 'merchant_public_key', 'status', @@ -47,7 +46,6 @@ class MerchantApiCredential extends BaseModel */ protected $casts = [ 'merchant_id' => 'integer', - 'sign_type' => 'integer', 'status' => 'integer', 'last_used_at' => 'datetime', 'created_at' => 'datetime', diff --git a/app/model/merchant/MerchantFundFreeze.php b/app/model/merchant/MerchantFundFreeze.php new file mode 100644 index 0000000..2fe4d97 --- /dev/null +++ b/app/model/merchant/MerchantFundFreeze.php @@ -0,0 +1,64 @@ + 'integer', + 'freeze_type' => 'integer', + 'freeze_amount' => 'integer', + 'remaining_amount' => 'integer', + 'status' => 'integer', + 'admin_id' => 'integer', + 'available_at' => 'datetime', + 'frozen_at' => 'datetime', + 'released_by' => 'integer', + 'released_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} diff --git a/app/model/payment/PayOrder.php b/app/model/payment/PayOrder.php index ebde151..f6bf452 100644 --- a/app/model/payment/PayOrder.php +++ b/app/model/payment/PayOrder.php @@ -6,7 +6,7 @@ use app\common\base\BaseModel; /** * 支付单模型。 - * 表示一次具体支付尝试,包含通道、状态、手续费快照和回调状态。 + * 表示一次具体支付尝试,包含通道、状态、平台服务费快照和回调状态。 */ class PayOrder extends BaseModel { @@ -40,12 +40,10 @@ class PayOrder extends BaseModel 'return_url', 'client_ip', 'device', - 'fee_rate_bp_snapshot', 'split_rate_bp_snapshot', - 'fee_estimated_amount', - 'fee_actual_amount', + 'service_fee_amount', 'status', - 'fee_status', + 'service_fee_status', 'settlement_status', 'channel_request_no', 'channel_order_no', @@ -82,12 +80,10 @@ class PayOrder extends BaseModel 'return_url' => 'string', 'client_ip' => 'string', 'device' => 'string', - 'fee_rate_bp_snapshot' => 'integer', 'split_rate_bp_snapshot' => 'integer', - 'fee_estimated_amount' => 'integer', - 'fee_actual_amount' => 'integer', + 'service_fee_amount' => 'integer', 'status' => 'integer', - 'fee_status' => 'integer', + 'service_fee_status' => 'integer', 'settlement_status' => 'integer', 'request_at' => 'datetime', 'paid_at' => 'datetime', @@ -102,5 +98,3 @@ class PayOrder extends BaseModel 'updated_at' => 'datetime', ]; } - - diff --git a/app/model/payment/RefundOrder.php b/app/model/payment/RefundOrder.php index 5edc8c9..0ff4ccb 100644 --- a/app/model/payment/RefundOrder.php +++ b/app/model/payment/RefundOrder.php @@ -6,7 +6,7 @@ use app\common\base\BaseModel; /** * 退款单模型。 - * 当前按整单全额退款设计,因此同一支付单只允许一张退款单。 + * 支持同一支付单按商户退款号发起多笔部分退款。 */ class RefundOrder extends BaseModel { @@ -71,4 +71,3 @@ class RefundOrder extends BaseModel - diff --git a/app/process/PaymentRuntimeProcess.php b/app/process/PaymentRuntimeProcess.php index e9f18d7..bc189bb 100644 --- a/app/process/PaymentRuntimeProcess.php +++ b/app/process/PaymentRuntimeProcess.php @@ -76,13 +76,15 @@ class PaymentRuntimeProcess ) ); - $this->runIfDue( - 'order_timeout', - $this->intConfig('pay_order_timeout_scan_interval_seconds', 60, 5), - fn (): array => $this->maintenanceService()->timeoutExpiredPayOrders( - $this->intConfig('pay_order_timeout_batch_size', 100, 1) - ) - ); + if ($this->boolConfig('pay_order_timeout_enabled', true)) { + $this->runIfDue( + 'order_timeout', + $this->intConfig('pay_order_timeout_scan_interval_seconds', 60, 5), + fn (): array => $this->maintenanceService()->timeoutExpiredPayOrders( + $this->intConfig('pay_order_timeout_batch_size', 100, 1) + ) + ); + } if ($this->boolConfig('pay_active_query_enabled', true)) { $this->runIfDue( @@ -161,7 +163,7 @@ class PaymentRuntimeProcess */ private function maintenanceService(): PaymentRuntimeMaintenanceService { - return container_make(PaymentRuntimeMaintenanceService::class, []); + return container_get(PaymentRuntimeMaintenanceService::class); } /** @@ -216,6 +218,6 @@ class PaymentRuntimeProcess */ private function runtimeConfig(): SystemConfigRuntimeService { - return container_make(SystemConfigRuntimeService::class, []); + return container_get(SystemConfigRuntimeService::class); } } diff --git a/app/process/ReceiptWatcherProcess.php b/app/process/ReceiptWatcherProcess.php new file mode 100644 index 0000000..d10f1cb --- /dev/null +++ b/app/process/ReceiptWatcherProcess.php @@ -0,0 +1,173 @@ + + */ + private array $lastRunAt = []; + + /** + * 运行锁。 + * + * @var array + */ + private array $running = []; + + /** + * 构造方法。 + * + * @param array $options 进程选项 + */ + public function __construct( + private array $options = [] + ) { + } + + /** + * Worker 启动。 + * + * @param Worker $worker Worker 实例 + * @return void + */ + public function onWorkerStart(Worker $worker): void + { + try { + $this->watcherService()->refreshChannelCache(); + } catch (\Throwable $e) { + Log::warning('[ReceiptWatcherProcess] 启动刷新账号缓存失败:' . $e->getMessage()); + } + + $heartbeat = $this->intOption('heartbeat_seconds', 1, 1, 60); + Timer::add($heartbeat, function (): void { + $this->tick(); + }); + + Log::info(sprintf('[ReceiptWatcherProcess] 网页流水监听调度进程已启动 heartbeat=%s', $heartbeat)); + } + + /** + * 心跳调度入口。 + * + * @return void + */ + private function tick(): void + { + try { + $this->runIfDue('refresh_channels', 60, function (): array { + return $this->watcherService()->refreshChannelCache(); + }); + + $this->runIfDue('sync_pending_orders', $this->scanIntervalSeconds(), function (): array { + return $this->watcherService()->syncPendingOrders($this->scanBatchSize()); + }); + } catch (\Throwable $e) { + Log::warning('[ReceiptWatcherProcess] 心跳调度失败:' . $e->getMessage()); + } + } + + /** + * 到期后执行任务。 + * + * @param string $key 任务键 + * @param int $intervalSeconds 间隔秒数 + * @param callable $callback 任务回调 + * @return void + */ + private function runIfDue(string $key, int $intervalSeconds, callable $callback): void + { + $now = time(); + $lastRunAt = (int) ($this->lastRunAt[$key] ?? 0); + if ($lastRunAt > 0 && $now - $lastRunAt < $intervalSeconds) { + return; + } + if (!empty($this->running[$key])) { + return; + } + + $this->lastRunAt[$key] = $now; + $this->running[$key] = true; + + try { + $summary = $callback(); + if ($this->hasWork($summary)) { + Log::info(sprintf( + '[ReceiptWatcherProcess] %s 执行完成 %s', + $key, + json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + )); + } + } catch (\Throwable $e) { + Log::warning(sprintf('[ReceiptWatcherProcess] %s 执行失败:%s', $key, $e->getMessage())); + } finally { + $this->running[$key] = false; + } + } + + /** + * @param array $summary 任务摘要 + * @return bool 是否有实际工作量 + */ + private function hasWork(array $summary): bool + { + foreach ($summary as $value) { + if ((int) $value > 0) { + return true; + } + } + + return false; + } + + /** + * @return int 待支付订单扫描间隔 + */ + private function scanIntervalSeconds(): int + { + return max(2, (int) sys_config('receipt_watcher_order_scan_interval_seconds', 3)); + } + + /** + * @return int 待支付订单扫描批量 + */ + private function scanBatchSize(): int + { + return max(1, (int) sys_config('receipt_watcher_order_scan_batch_size', 500)); + } + + /** + * @param string $key 配置键 + * @param int $default 默认值 + * @param int $min 最小值 + * @param int $max 最大值 + * @return int 配置值 + */ + private function intOption(string $key, int $default, int $min, int $max): int + { + $value = (int) ($this->options[$key] ?? $default); + + return min($max, max($min, $value)); + } + + /** + * @return ReceiptWatcherService 网页流水监听服务 + */ + private function watcherService(): ReceiptWatcherService + { + return container_get(ReceiptWatcherService::class); + } +} diff --git a/app/queue/job/MerchantNotifyJob.php b/app/queue/job/MerchantNotifyJob.php new file mode 100644 index 0000000..0293e58 --- /dev/null +++ b/app/queue/job/MerchantNotifyJob.php @@ -0,0 +1,48 @@ + $data 队列消息 + * @return void + */ + public function handle(array $data): void + { + $notifyNo = $this->requireString($data, 'notify_no'); + $this->dispatcher->dispatchTask($notifyNo, false); + } + + /** + * 获取日志名称。 + * + * @return string 日志名称 + */ + protected function logName(): string + { + return 'MerchantNotifyQueue'; + } +} diff --git a/app/queue/job/README.md b/app/queue/job/README.md new file mode 100644 index 0000000..97ac8ac --- /dev/null +++ b/app/queue/job/README.md @@ -0,0 +1,26 @@ +# Queue Job 目录说明 + +本目录只放具体的队列业务任务类。 + +Job 的职责是: + +- 校验队列消息 payload。 +- 调用领域 Service 完成业务动作。 +- 定义该任务自己的失败处理策略。 + +Job 不直接实现 `Webman\RedisQueue\Consumer`,也不声明队列名。队列名和 Redis 连接由 `app/queue/redis` 下的 Consumer 负责。 + +## 新增任务约定 + +1. 新建一个以 `Job` 结尾的类,例如 `TransferDispatchJob`。 +2. 继承 `app\queue\support\AbstractQueueJob`。 +3. 在 `handle(array $data)` 中解析消息并调用对应 Service。 +4. 不在 Job 中堆复杂业务逻辑,复杂流程应下沉到 `app/service`。 +5. Job 应保持无状态,单次消息的数据只从 `handle()` 参数传入。 + +## 目录边界 + +- 具体业务 Job 放这里。 +- 队列 Consumer 放 `app/queue/redis`。 +- 抽象基类和队列辅助类放 `app/queue/support`。 +- 通用接口放 `app/common/interface`。 diff --git a/app/queue/job/ReceiptFlowNotifyJob.php b/app/queue/job/ReceiptFlowNotifyJob.php new file mode 100644 index 0000000..4b5b351 --- /dev/null +++ b/app/queue/job/ReceiptFlowNotifyJob.php @@ -0,0 +1,134 @@ + $data 队列消息 + * @return void + */ + public function handle(array $data): void + { + $pluginCode = $this->requireString($data, 'plugin_code', '插件编码'); + $apiConfigId = (int) ($data['api_config_id'] ?? 0); + if ($apiConfigId <= 0) { + throw new RuntimeException('插件配置ID不能为空'); + } + + $records = $this->records($data); + foreach ($records as $record) { + $this->handleRecord($pluginCode, $apiConfigId, $data, $record); + } + } + + /** + * 处理单条流水。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $data 原始队列消息 + * @param array $record 流水记录 + * @return void + */ + private function handleRecord(string $pluginCode, int $apiConfigId, array $data, array $record): void + { + if ($this->receiptWatcherService->isFlowSeen($pluginCode, $apiConfigId, $record)) { + return; + } + + $token = $this->receiptWatcherService->acquireFlowLock($pluginCode, $apiConfigId, $record); + if ($token === null) { + return; + } + + try { + $payType = trim((string) ($record['pay_type'] ?? '')); + $channel = $this->receiptWatcherService->resolveChannelForFlow($pluginCode, $apiConfigId, $payType); + if (!$channel) { + throw new PaymentException('流水未匹配到可用支付通道', 40200, [ + 'plugin_code' => $pluginCode, + 'api_config_id' => $apiConfigId, + 'pay_type' => $payType, + ]); + } + + $payload = $data; + $payload['record'] = $record; + $payload['channel_id'] = (int) $channel->id; + $callbackPayload = $this->payOrderCallbackService->handleChannelNotifyPayload((int) $channel->id, $payload); + if (empty($callbackPayload['success'])) { + throw new RuntimeException('流水通知未确认支付成功'); + } + $this->receiptWatcherService->markFlowSeen($pluginCode, $apiConfigId, $record); + } finally { + $this->receiptWatcherService->releaseFlowLock($pluginCode, $apiConfigId, $record, $token); + } + } + + /** + * 读取消息中的流水列表。 + * + * @param array $data 队列消息 + * @return array> 流水列表 + */ + private function records(array $data): array + { + if (isset($data['record']) && is_array($data['record'])) { + return [$data['record']]; + } + + if (!isset($data['records']) || !is_array($data['records'])) { + throw new RuntimeException('流水记录不能为空'); + } + + $records = []; + foreach ($data['records'] as $record) { + if (is_array($record)) { + $records[] = $record; + } + } + + if ($records === []) { + throw new RuntimeException('流水记录不能为空'); + } + + return $records; + } + + /** + * 获取日志名称。 + * + * @return string 日志名称 + */ + protected function logName(): string + { + return 'ReceiptFlowNotifyQueue'; + } +} diff --git a/app/queue/job/RefundDispatchJob.php b/app/queue/job/RefundDispatchJob.php new file mode 100644 index 0000000..75d81fd --- /dev/null +++ b/app/queue/job/RefundDispatchJob.php @@ -0,0 +1,49 @@ + $data 队列消息 + * @return void + */ + public function handle(array $data): void + { + $refundNo = $this->requireString($data, 'refund_no'); + $isRetry = $this->boolValue($data['is_retry'] ?? false); + + $this->dispatcher->dispatch($refundNo, $isRetry, false); + } + + /** + * 获取日志名称。 + * + * @return string 日志名称 + */ + protected function logName(): string + { + return 'RefundDispatchQueue'; + } +} diff --git a/app/queue/job/SettlementCompleteJob.php b/app/queue/job/SettlementCompleteJob.php new file mode 100644 index 0000000..384f093 --- /dev/null +++ b/app/queue/job/SettlementCompleteJob.php @@ -0,0 +1,48 @@ + $data 队列消息 + * @return void + */ + public function handle(array $data): void + { + $settleNo = $this->requireString($data, 'settle_no', '清算单号'); + + $this->settlementAutomationService->completeAutoSettlement($settleNo); + } + + /** + * 获取日志名称。 + * + * @return string 日志名称 + */ + protected function logName(): string + { + return 'SettlementCompleteQueue'; + } +} diff --git a/app/queue/job/TransferDispatchJob.php b/app/queue/job/TransferDispatchJob.php new file mode 100644 index 0000000..8908a0f --- /dev/null +++ b/app/queue/job/TransferDispatchJob.php @@ -0,0 +1,47 @@ + $data 队列消息 + * @return void + */ + public function handle(array $data): void + { + $bizNo = $this->requireString($data, 'biz_no'); + $this->transferService->dispatchQueuedTransfer($bizNo); + } + + /** + * 获取日志名称。 + * + * @return string 日志名称 + */ + protected function logName(): string + { + return 'TransferDispatchQueue'; + } +} diff --git a/app/queue/job/TransferQueryJob.php b/app/queue/job/TransferQueryJob.php new file mode 100644 index 0000000..2f88c9a --- /dev/null +++ b/app/queue/job/TransferQueryJob.php @@ -0,0 +1,49 @@ + $data 队列消息 + * @return void + */ + public function handle(array $data): void + { + $bizNo = $this->requireString($data, 'biz_no'); + $attempt = max(0, (int) ($data['attempt'] ?? 0)); + + $this->transferService->queryQueuedTransfer($bizNo, $attempt); + } + + /** + * 获取日志名称。 + * + * @return string 日志名称 + */ + protected function logName(): string + { + return 'TransferQueryQueue'; + } +} diff --git a/app/queue/redis/MerchantNotify.php b/app/queue/redis/MerchantNotify.php new file mode 100644 index 0000000..7499cd6 --- /dev/null +++ b/app/queue/redis/MerchantNotify.php @@ -0,0 +1,32 @@ + 任务类名 + */ + protected function jobClass(): string + { + return MerchantNotifyJob::class; + } +} diff --git a/app/queue/redis/README.md b/app/queue/redis/README.md new file mode 100644 index 0000000..1b2cba0 --- /dev/null +++ b/app/queue/redis/README.md @@ -0,0 +1,33 @@ +# Redis Consumer 目录说明 + +本目录只放会被 `webman/redis-queue` 扫描的正式 Redis 队列 Consumer。 + +当前配置位于 `config/plugin/webman/redis-queue/process.php`,其中 `consumer_dir` 指向本目录。队列进程启动时会递归扫描本目录下的 PHP 文件,只要类实现了 `Webman\RedisQueue\Consumer`,就会被实例化并订阅队列。 + +## Consumer 职责 + +Consumer 应保持很薄,只负责: + +- 声明队列名。 +- 声明对应 Job 类。 +- 适配 Redis 队列框架。 + +队列名统一从 `app\common\constant\PaymentQueueConstant` 引用,避免生产者和消费者各自维护字符串。 + +业务处理应放在 `app/queue/job` 下的 Job 类中。 + +## 禁止放入 + +- 抽象基类。 +- 示例 Consumer。 +- 不希望生产环境订阅的临时测试类。 +- 复杂业务逻辑。 + +这些文件可能被队列进程扫描并误订阅。 + +## 新增任务约定 + +1. 先在 `app/common/constant/PaymentQueueConstant.php` 登记队列名。 +2. 再在 `app/queue/job` 新增业务 Job。 +3. 最后在本目录新增 Consumer,继承 `app\queue\support\AbstractRedisConsumer`。 +4. Consumer 只设置 `$queue`,并通过 `jobClass()` 返回对应 Job 类名。 diff --git a/app/queue/redis/ReceiptFlowNotify.php b/app/queue/redis/ReceiptFlowNotify.php new file mode 100644 index 0000000..773532f --- /dev/null +++ b/app/queue/redis/ReceiptFlowNotify.php @@ -0,0 +1,30 @@ + 任务类名 + */ + protected function jobClass(): string + { + return ReceiptFlowNotifyJob::class; + } +} diff --git a/app/queue/redis/RefundDispatch.php b/app/queue/redis/RefundDispatch.php new file mode 100644 index 0000000..aeed2db --- /dev/null +++ b/app/queue/redis/RefundDispatch.php @@ -0,0 +1,32 @@ + 任务类名 + */ + protected function jobClass(): string + { + return RefundDispatchJob::class; + } +} diff --git a/app/queue/redis/SettlementComplete.php b/app/queue/redis/SettlementComplete.php new file mode 100644 index 0000000..38c6e89 --- /dev/null +++ b/app/queue/redis/SettlementComplete.php @@ -0,0 +1,30 @@ + 任务类名 + */ + protected function jobClass(): string + { + return SettlementCompleteJob::class; + } +} diff --git a/app/queue/redis/TransferDispatch.php b/app/queue/redis/TransferDispatch.php new file mode 100644 index 0000000..6180ce0 --- /dev/null +++ b/app/queue/redis/TransferDispatch.php @@ -0,0 +1,32 @@ + 任务类名 + */ + protected function jobClass(): string + { + return TransferDispatchJob::class; + } +} diff --git a/app/queue/redis/TransferQuery.php b/app/queue/redis/TransferQuery.php new file mode 100644 index 0000000..9aa509b --- /dev/null +++ b/app/queue/redis/TransferQuery.php @@ -0,0 +1,32 @@ + 任务类名 + */ + protected function jobClass(): string + { + return TransferQueryJob::class; + } +} diff --git a/app/queue/support/AbstractQueueJob.php b/app/queue/support/AbstractQueueJob.php new file mode 100644 index 0000000..809f1fd --- /dev/null +++ b/app/queue/support/AbstractQueueJob.php @@ -0,0 +1,73 @@ + $package 原始队列包 + * @return void + */ + public function failed(Throwable $exception, array $package): void + { + Log::warning(sprintf( + '[%s] 消费失败 queue=%s attempts=%s error=%s', + $this->logName(), + (string) ($package['queue'] ?? ''), + (string) ($package['attempts'] ?? ''), + $exception->getMessage() + )); + } + + /** + * 读取必填字符串字段。 + * + * @param array $data 队列消息 + * @param string $key 字段名 + * @param string $label 字段显示名 + * @return string 字段值 + */ + protected function requireString(array $data, string $key, string $label = ''): string + { + $value = trim((string) ($data[$key] ?? '')); + if ($value === '') { + throw new RuntimeException(($label !== '' ? $label : $key) . ' 不能为空'); + } + + return $value; + } + + /** + * 解析布尔字段。 + * + * @param mixed $value 字段值 + * @return bool 布尔结果 + */ + protected function boolValue(mixed $value): bool + { + return filter_var($value, FILTER_VALIDATE_BOOL); + } + + /** + * 获取日志名称。 + * + * @return string 日志名称 + */ + protected function logName(): string + { + return static::class; + } +} diff --git a/app/queue/support/AbstractRedisConsumer.php b/app/queue/support/AbstractRedisConsumer.php new file mode 100644 index 0000000..62c7fa1 --- /dev/null +++ b/app/queue/support/AbstractRedisConsumer.php @@ -0,0 +1,82 @@ + 任务类名 + */ + abstract protected function jobClass(): string; + + /** + * 消费队列消息。 + * + * @param mixed $data 队列消息 + * @return void + */ + public function consume($data): void + { + $this->job()->handle(is_array($data) ? $data : []); + } + + /** + * 处理消费失败。 + * + * @param Throwable $exception 异常 + * @param array $package 原始队列包 + * @return void + */ + public function onConsumeFailure(Throwable $exception, array $package): void + { + try { + $this->job()->failed($exception, $package); + } catch (Throwable $failureException) { + Log::warning(sprintf( + '[QueueConsumer] 失败处理异常 job=%s queue=%s error=%s failure_error=%s', + $this->jobClass(), + (string) ($package['queue'] ?? ''), + $exception->getMessage(), + $failureException->getMessage() + )); + } + } + + /** + * 从容器中获取任务实例。 + * + * Job 不保存单次消费的可变状态,使用 container_get 复用实例,避免每条消息重复构造依赖。 + * + * @return QueueJobInterface 任务实例 + */ + private function job(): QueueJobInterface + { + $job = container_get($this->jobClass()); + if (!$job instanceof QueueJobInterface) { + throw new RuntimeException('队列任务必须实现 QueueJobInterface:' . $this->jobClass()); + } + + return $job; + } +} diff --git a/app/repository/account/freeze/MerchantFundFreezeRepository.php b/app/repository/account/freeze/MerchantFundFreezeRepository.php new file mode 100644 index 0000000..9b5db72 --- /dev/null +++ b/app/repository/account/freeze/MerchantFundFreezeRepository.php @@ -0,0 +1,120 @@ +activeQuery($now) + ->where('pay_no', $payNo) + ->orderByDesc('id') + ->first($columns); + } + + /** + * 加锁查询指定支付单当前有效冻结。 + * + * @param string $payNo 支付单号 + * @param string $now 当前时间 + * @param array $columns 字段列表 + * @return MerchantFundFreeze|null 冻结记录 + */ + public function firstActiveForUpdateByPayNo(string $payNo, string $now, array $columns = ['*']) + { + return $this->activeQuery($now) + ->where('pay_no', $payNo) + ->orderByDesc('id') + ->lockForUpdate() + ->first($columns); + } + + /** + * 加锁查询指定支付单和冻结类型的有效冻结。 + * + * @param string $payNo 支付单号 + * @param int $freezeType 冻结类型 + * @param string $now 当前时间 + * @param array $columns 字段列表 + * @return MerchantFundFreeze|null 冻结记录 + */ + public function firstActiveForUpdateByPayNoAndType(string $payNo, int $freezeType, string $now, array $columns = ['*']) + { + return $this->activeQuery($now) + ->where('pay_no', $payNo) + ->where('freeze_type', $freezeType) + ->orderByDesc('id') + ->lockForUpdate() + ->first($columns); + } + + /** + * 查询指定支付单是否存在有效冻结。 + * + * @param string $payNo 支付单号 + * @param string $now 当前时间 + * @return bool 是否存在 + */ + public function existsActiveByPayNo(string $payNo, string $now): bool + { + return $this->activeQuery($now) + ->where('pay_no', $payNo) + ->exists(); + } + + /** + * 统计商户当前有效冻结金额。 + * + * @param int $merchantId 商户ID + * @param string $now 当前时间 + * @return int 有效冻结金额,单位分 + */ + public function sumActiveAmountByMerchant(int $merchantId, string $now): int + { + return (int) $this->activeQuery($now) + ->where('merchant_id', $merchantId) + ->sum('remaining_amount'); + } + + /** + * 有效冻结查询基础条件。 + * + * 到期时间只表示“允许释放的最早时间”,未释放前仍计入账户冻结余额。 + * + * @param string $now 当前时间 + * @return \Illuminate\Database\Eloquent\Builder 查询构造器 + */ + public function activeQuery(string $now) + { + return $this->query() + ->where('status', FundFreezeConstant::STATUS_ACTIVE) + ->where('remaining_amount', '>', 0); + } +} diff --git a/app/repository/ops/stat/ChannelDailyStatRepository.php b/app/repository/ops/stat/ChannelDailyStatRepository.php index c1aabbf..cc48c7a 100644 --- a/app/repository/ops/stat/ChannelDailyStatRepository.php +++ b/app/repository/ops/stat/ChannelDailyStatRepository.php @@ -37,10 +37,26 @@ class ChannelDailyStatRepository extends BaseRepository ->where('stat_date', $statDate) ->first($columns); } + + /** + * 根据通道和日期加锁查询统计记录。 + * + * @param int $channelId 渠道ID + * @param string $statDate 统计日期 + * @param array $columns 字段列表 + * @return ChannelDailyStat|null 统计记录 + */ + public function findForUpdateByChannelAndDate(int $channelId, string $statDate, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('channel_id', $channelId) + ->where('stat_date', $statDate) + ->lockForUpdate() + ->first($columns); + } } - diff --git a/app/repository/payment/config/PaymentChannelRepository.php b/app/repository/payment/config/PaymentChannelRepository.php index 69a7a49..ce10440 100644 --- a/app/repository/payment/config/PaymentChannelRepository.php +++ b/app/repository/payment/config/PaymentChannelRepository.php @@ -3,6 +3,7 @@ namespace app\repository\payment\config; use app\common\base\BaseRepository; +use app\common\constant\CommonConstant; use app\model\payment\PaymentChannel; /** @@ -122,6 +123,83 @@ class PaymentChannelRepository extends BaseRepository ->where('merchant_id', $merchantId) ->count(); } + + /** + * 查询网页流水监听可用通道。 + * + * @param array $pluginCodes 支持监听的插件编码 + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 通道列表 + */ + public function listReceiptWatcherChannels(array $pluginCodes, array $columns = ['*']) + { + $pluginCodes = array_values(array_filter(array_map(static fn ($code): string => trim((string) $code), $pluginCodes))); + if ($pluginCodes === []) { + return $this->model->newCollection(); + } + + return $this->model->newQuery() + ->where('status', CommonConstant::STATUS_ENABLED) + ->where('api_config_id', '>', 0) + ->whereIn('plugin_code', $pluginCodes) + ->orderBy('api_config_id') + ->orderBy('id') + ->get($columns); + } + + /** + * 查询指定插件配置下的通道 ID。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @return array 通道 ID 列表 + */ + public function idsByPluginConfig(string $pluginCode, int $apiConfigId): array + { + if (trim($pluginCode) === '' || $apiConfigId <= 0) { + return []; + } + + return $this->model->newQuery() + ->where('plugin_code', $pluginCode) + ->where('api_config_id', $apiConfigId) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->all(); + } + + /** + * 根据插件配置和支付方式解析流水对应通道。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param string $payTypeCode 支付方式编码 + * @return PaymentChannel|null 支付通道 + */ + public function findReceiptFlowChannel(string $pluginCode, int $apiConfigId, string $payTypeCode): ?PaymentChannel + { + $pluginCode = trim($pluginCode); + $payTypeCode = trim($payTypeCode); + if ($pluginCode === '' || $apiConfigId <= 0) { + return null; + } + + $query = $this->model->newQuery() + ->from('ma_payment_channel as c') + ->where('c.plugin_code', $pluginCode) + ->where('c.api_config_id', $apiConfigId) + ->where('c.status', CommonConstant::STATUS_ENABLED) + ->orderBy('c.sort_no') + ->orderBy('c.id'); + + if ($payTypeCode !== '') { + $query->join('ma_payment_type as t', 'c.pay_type_id', '=', 't.id') + ->where('t.code', $payTypeCode); + } + + /** @var PaymentChannel|null $channel */ + $channel = $query->first(['c.*']); + return $channel; + } } - diff --git a/app/repository/payment/config/PaymentPluginConfRepository.php b/app/repository/payment/config/PaymentPluginConfRepository.php index f99aadb..89dd354 100644 --- a/app/repository/payment/config/PaymentPluginConfRepository.php +++ b/app/repository/payment/config/PaymentPluginConfRepository.php @@ -53,9 +53,27 @@ class PaymentPluginConfRepository extends BaseRepository ->whereKey($id) ->first($columns); } + + /** + * 根据配置 ID 批量查询插件配置。 + * + * @param array $ids 配置 ID 列表 + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 插件配置列表 + */ + public function listByIds(array $ids, array $columns = ['*']) + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + if ($ids === []) { + return $this->model->newCollection(); + } + + return $this->model->newQuery() + ->whereIn('id', $ids) + ->get($columns); + } } - diff --git a/app/repository/payment/config/PaymentPollGroupBindRepository.php b/app/repository/payment/config/PaymentPollGroupBindRepository.php index 5474269..7bbfa4c 100644 --- a/app/repository/payment/config/PaymentPollGroupBindRepository.php +++ b/app/repository/payment/config/PaymentPollGroupBindRepository.php @@ -61,6 +61,7 @@ class PaymentPollGroupBindRepository extends BaseRepository 'b.remark', 't.code as pay_type_code', 't.name as pay_type_name', + 't.icon as pay_type_icon', 'p.group_name as poll_group_name', 'p.route_mode', ]); @@ -70,4 +71,3 @@ class PaymentPollGroupBindRepository extends BaseRepository - diff --git a/app/repository/payment/config/PaymentTypeRepository.php b/app/repository/payment/config/PaymentTypeRepository.php index f19dca6..e992c25 100644 --- a/app/repository/payment/config/PaymentTypeRepository.php +++ b/app/repository/payment/config/PaymentTypeRepository.php @@ -49,10 +49,28 @@ class PaymentTypeRepository extends BaseRepository ->where('code', $code) ->first($columns); } + + /** + * 根据支付方式 ID 批量查询字典。 + * + * @param array $ids 支付方式 ID 列表 + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 支付方式列表 + */ + public function listByIds(array $ids, array $columns = ['*']) + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + if ($ids === []) { + return $this->model->newCollection(); + } + + return $this->model->newQuery() + ->whereIn('id', $ids) + ->get($columns); + } } - diff --git a/app/repository/payment/trade/PayOrderRepository.php b/app/repository/payment/trade/PayOrderRepository.php index 7b62b46..84a305a 100644 --- a/app/repository/payment/trade/PayOrderRepository.php +++ b/app/repository/payment/trade/PayOrderRepository.php @@ -226,8 +226,156 @@ class PayOrderRepository extends BaseRepository 'c.name as channel_name', ]); } + + /** + * 查询网页流水监听需要关注的待支付订单。 + * + * @param array $pluginCodes 插件编码列表 + * @param string $now 当前时间 + * @param int $limit 限制条数 + * @return \Illuminate\Database\Eloquent\Collection 支付单列表 + */ + public function listReceiptWatcherPendingOrders(array $pluginCodes, string $now, int $limit = 500) + { + $pluginCodes = array_values(array_filter(array_map(static fn ($code): string => trim((string) $code), $pluginCodes))); + if ($pluginCodes === []) { + return $this->model->newCollection(); + } + + return $this->model->newQuery() + ->from('ma_pay_order as po') + ->join('ma_payment_channel as c', 'po.channel_id', '=', 'c.id') + ->leftJoin('ma_payment_type as t', 'po.pay_type_id', '=', 't.id') + ->whereIn('po.status', TradeConstant::orderMutableStatuses()) + ->whereIn('c.plugin_code', $pluginCodes) + ->where('c.status', 1) + ->where(function ($query) use ($now): void { + $query->whereNull('po.expire_at') + ->orWhere('po.expire_at', '>', $now); + }) + ->orderBy('po.request_at') + ->orderBy('po.id') + ->limit(max(1, $limit)) + ->get([ + 'po.id', + 'po.pay_no', + 'po.channel_id', + 'po.pay_type_id', + 'po.pay_amount', + 'po.channel_order_no', + 'po.channel_trade_no', + 'po.request_at', + 'po.expire_at', + 'po.created_at', + 'c.plugin_code', + 'c.api_config_id', + 't.code as pay_type', + ]); + } + + /** + * 查询同一收款账号下已占用的识别金额。 + * + * @param array $channelIds 通道ID列表 + * @param string $excludePayNo 排除的支付单号 + * @param string $now 当前时间 + * @return array 已占用金额列表,单位分 + */ + public function listUsedReceiptAmounts(array $channelIds, string $excludePayNo, string $now): array + { + $channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds)))); + if ($channelIds === []) { + return []; + } + + return $this->model->newQuery() + ->whereIn('channel_id', $channelIds) + ->where('pay_no', '<>', $excludePayNo) + ->whereIn('status', TradeConstant::orderMutableStatuses()) + ->where('expire_at', '>', $now) + ->lockForUpdate() + ->pluck('pay_amount') + ->map(fn ($amount): int => (int) $amount) + ->all(); + } + + /** + * 根据第三方流水号匹配支付单。 + * + * @param array $channelIds 通道ID列表 + * @param string $orderNo 第三方流水号 + * @param array $columns 字段列表 + * @return PayOrder|null 支付单 + */ + public function findByReceiptChannelOrder(array $channelIds, string $orderNo, array $columns = ['*']): ?PayOrder + { + $channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds)))); + $orderNo = trim($orderNo); + if ($channelIds === [] || $orderNo === '') { + return null; + } + + return $this->model->newQuery() + ->whereIn('channel_id', $channelIds) + ->where(function ($query) use ($orderNo): void { + $query->where('channel_order_no', $orderNo) + ->orWhere('channel_trade_no', $orderNo); + }) + ->orderByDesc('id') + ->first($columns); + } + + /** + * 根据金额查询同一收款账号下有效待支付订单。 + * + * @param array $channelIds 通道ID列表 + * @param int $amount 金额,单位分 + * @param int $payTypeId 支付方式ID + * @param string $now 当前时间 + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 支付单列表 + */ + public function listMutableReceiptOrdersByAmount(array $channelIds, int $amount, int $payTypeId, string $now, array $columns = ['*']) + { + $channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds)))); + if ($channelIds === []) { + return $this->model->newCollection(); + } + + $query = $this->model->newQuery() + ->whereIn('channel_id', $channelIds) + ->where('pay_amount', $amount) + ->whereIn('status', TradeConstant::orderMutableStatuses()) + ->where('expire_at', '>', $now); + + if ($payTypeId > 0) { + $query->where('pay_type_id', $payTypeId); + } + + return $query->get($columns); + } + + /** + * 查询同一收款账号下有效待支付订单,用于备注匹配。 + * + * @param array $channelIds 通道ID列表 + * @param string $now 当前时间 + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 支付单列表 + */ + public function listMutableReceiptOrders(array $channelIds, string $now, array $columns = ['*']) + { + $channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds)))); + if ($channelIds === []) { + return $this->model->newCollection(); + } + + return $this->model->newQuery() + ->whereIn('channel_id', $channelIds) + ->whereIn('status', TradeConstant::orderMutableStatuses()) + ->where('expire_at', '>', $now) + ->get($columns); + } } - - diff --git a/app/repository/payment/trade/RefundOrderRepository.php b/app/repository/payment/trade/RefundOrderRepository.php index b62d09a..1f08f1c 100644 --- a/app/repository/payment/trade/RefundOrderRepository.php +++ b/app/repository/payment/trade/RefundOrderRepository.php @@ -157,6 +157,23 @@ class RefundOrderRepository extends BaseRepository ->first($columns); } + /** + * 锁定指定支付单下会占用可退余额的退款单。 + * + * @param string $payNo 支付单号 + * @param array $statuses 状态列表 + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 退款单列表 + */ + public function listForUpdateByPayNoAndStatuses(string $payNo, array $statuses, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('pay_no', $payNo) + ->whereIn('status', $statuses) + ->lockForUpdate() + ->get($columns); + } + /** * 统计商户下的退款订单数量。 * @@ -172,4 +189,3 @@ class RefundOrderRepository extends BaseRepository } - diff --git a/app/repository/system/config/SystemConfigRepository.php b/app/repository/system/config/SystemConfigRepository.php index a704021..fd045d3 100644 --- a/app/repository/system/config/SystemConfigRepository.php +++ b/app/repository/system/config/SystemConfigRepository.php @@ -19,9 +19,41 @@ class SystemConfigRepository extends BaseRepository { parent::__construct(new SystemConfig()); } + + /** + * 按配置键批量查询配置值。 + * + * @param array $keys 配置键列表 + * @return array 配置键到配置值的映射 + */ + public function valueMapByKeys(array $keys): array + { + $normalizedKeys = []; + foreach ($keys as $key) { + $configKey = strtolower(trim((string) $key)); + if ($configKey !== '') { + $normalizedKeys[$configKey] = true; + } + } + + $keys = array_keys($normalizedKeys); + if ($keys === []) { + return []; + } + + $rows = $this->query() + ->whereIn('config_key', $keys) + ->get(['config_key', 'config_value']); + + $values = []; + foreach ($rows as $row) { + $values[strtolower((string) $row->config_key)] = (string) ($row->config_value ?? ''); + } + + return $values; + } } - diff --git a/app/route/admin.php b/app/route/admin.php index 71d9d26..a59cac7 100644 --- a/app/route/admin.php +++ b/app/route/admin.php @@ -2,181 +2,263 @@ use Webman\Route; use app\common\middleware\Cors; -use app\http\admin\controller\system\AdminUserController; -use app\http\admin\controller\system\AuthController; use app\http\admin\controller\account\MerchantAccountController; use app\http\admin\controller\account\MerchantAccountLedgerController; +use app\http\admin\controller\file\FileRecordController; +use app\http\admin\controller\merchant\MerchantApiCredentialController; +use app\http\admin\controller\merchant\MerchantController; +use app\http\admin\controller\merchant\MerchantGroupController; +use app\http\admin\controller\merchant\MerchantPolicyController; use app\http\admin\controller\ops\ChannelDailyStatController; use app\http\admin\controller\ops\ChannelNotifyLogController; use app\http\admin\controller\ops\MerchantNotifyTaskController; -use app\http\admin\controller\merchant\MerchantController; -use app\http\admin\controller\merchant\MerchantApiCredentialController; -use app\http\admin\controller\merchant\MerchantGroupController; -use app\http\admin\controller\merchant\MerchantPolicyController; +use app\http\admin\controller\ops\PayCallbackLogController; use app\http\admin\controller\payment\PaymentChannelController; -use app\http\admin\controller\payment\PaymentPluginController; use app\http\admin\controller\payment\PaymentPluginConfController; -use app\http\admin\controller\payment\PaymentPollGroupController; +use app\http\admin\controller\payment\PaymentPluginController; use app\http\admin\controller\payment\PaymentPollGroupBindController; use app\http\admin\controller\payment\PaymentPollGroupChannelController; +use app\http\admin\controller\payment\PaymentPollGroupController; use app\http\admin\controller\payment\PaymentTypeController; use app\http\admin\controller\payment\RouteController; -use app\http\admin\controller\trade\PayOrderController; -use app\http\admin\controller\ops\PayCallbackLogController; -use app\http\admin\controller\file\FileRecordController; -use app\http\admin\controller\trade\RefundOrderController; -use app\http\admin\controller\trade\SettlementOrderController; +use app\http\admin\controller\system\AdminUserController; +use app\http\admin\controller\system\AuthController; use app\http\admin\controller\system\SystemConfigPageController; use app\http\admin\controller\system\SystemController; +use app\http\admin\controller\trade\PayOrderController; +use app\http\admin\controller\trade\RefundOrderController; +use app\http\admin\controller\trade\SettlementOrderController; use app\http\admin\middleware\AdminAuthMiddleware; -Route::any('/admin[/{path:.+}]', function () { - return view('/public/admin/index'); -}); +$serveAdminApp = static function () { + $indexPath = public_path('admin/index.html'); + if (!is_file($indexPath)) { + return response('Admin page not found', 404); + } + return response(file_get_contents($indexPath), 200, [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); +}; + +// 管理后台项目:页面路由 +Route::any('/admin', $serveAdminApp); +Route::any('/admin/', $serveAdminApp); +Route::any('/admin/{path:.+}', $serveAdminApp); + +// 管理后台项目:接口路由 Route::group('/adminapi', function () { + // 公开接口 Route::post('/login', [AuthController::class, 'login'])->name('adminApiAuthLogin')->setParams(['real_name' => '管理员登录']); + Route::get('/system/public-config', [SystemController::class, 'publicConfig'])->name('adminApiSystemPublicConfig')->setParams(['real_name' => '管理后台公开配置']); Route::group('', function () { + // 会话与当前用户 Route::post('/logout', [AuthController::class, 'logout'])->name('adminApiAuthLogout')->setParams(['real_name' => '退出登录']); Route::get('/user/profile', [AuthController::class, 'profile'])->name('adminApiUserProfile')->setParams(['real_name' => '当前用户资料']); Route::post('/user/change-password', [AuthController::class, 'changePassword'])->name('adminApiUserChangePassword')->setParams(['real_name' => '修改当前管理员密码']); - Route::get('/merchants', [MerchantController::class, 'index'])->name('adminApiMerchantsIndex')->setParams(['real_name' => '商户列表']); - Route::get('/merchants/options', [MerchantController::class, 'options'])->name('adminApiMerchantsOptions')->setParams(['real_name' => '商户选项']); - Route::get('/merchants/select-options', [MerchantController::class, 'selectOptions'])->name('adminApiMerchantsSelectOptions')->setParams(['real_name' => '商户选择选项']); - Route::get('/merchants/{id}', [MerchantController::class, 'show'])->name('adminApiMerchantsShow')->setParams(['real_name' => '商户详情']); - Route::get('/merchants/{id}/overview', [MerchantController::class, 'overview'])->name('adminApiMerchantsOverview')->setParams(['real_name' => '商户总览']); - Route::post('/merchants', [MerchantController::class, 'store'])->name('adminApiMerchantsStore')->setParams(['real_name' => '新增商户']); - Route::put('/merchants/{id}', [MerchantController::class, 'update'])->name('adminApiMerchantsUpdate')->setParams(['real_name' => '更新商户']); - Route::delete('/merchants/{id}', [MerchantController::class, 'destroy'])->name('adminApiMerchantsDestroy')->setParams(['real_name' => '删除商户']); - Route::post('/merchants/{id}/reset-password', [MerchantController::class, 'resetPassword'])->name('adminApiMerchantsResetPassword')->setParams(['real_name' => '重置商户密码']); - Route::post('/merchants/{id}/issue-credential', [MerchantController::class, 'issueCredential'])->name('adminApiMerchantsIssueCredential')->setParams(['real_name' => '生成或重置商户 API 凭证']); + // 商户档案 + Route::group('/merchants', function () { + Route::get('', [MerchantController::class, 'index'])->name('adminApiMerchantsIndex')->setParams(['real_name' => '商户列表']); + Route::get('/options', [MerchantController::class, 'options'])->name('adminApiMerchantsOptions')->setParams(['real_name' => '商户选项']); + Route::get('/select-options', [MerchantController::class, 'selectOptions'])->name('adminApiMerchantsSelectOptions')->setParams(['real_name' => '商户选择选项']); + Route::post('', [MerchantController::class, 'store'])->name('adminApiMerchantsStore')->setParams(['real_name' => '新增商户']); + Route::get('/{id}/overview', [MerchantController::class, 'overview'])->name('adminApiMerchantsOverview')->setParams(['real_name' => '商户总览']); + Route::post('/{id}/reset-password', [MerchantController::class, 'resetPassword'])->name('adminApiMerchantsResetPassword')->setParams(['real_name' => '重置商户密码']); + Route::post('/{id}/login-token', [MerchantController::class, 'loginToken'])->name('adminApiMerchantsLoginToken')->setParams(['real_name' => '商户后台登录令牌']); + Route::post('/{id}/issue-credential', [MerchantController::class, 'issueCredential'])->name('adminApiMerchantsIssueCredential')->setParams(['real_name' => '生成或重置商户 API 凭证']); + Route::get('/{id}', [MerchantController::class, 'show'])->name('adminApiMerchantsShow')->setParams(['real_name' => '商户详情']); + Route::put('/{id}', [MerchantController::class, 'update'])->name('adminApiMerchantsUpdate')->setParams(['real_name' => '更新商户']); + Route::delete('/{id}', [MerchantController::class, 'destroy'])->name('adminApiMerchantsDestroy')->setParams(['real_name' => '删除商户']); + }); - Route::get('/admin-users', [AdminUserController::class, 'index'])->name('adminApiAdminUsersIndex')->setParams(['real_name' => '管理员列表']); - Route::get('/admin-users/{id}', [AdminUserController::class, 'show'])->name('adminApiAdminUsersShow')->setParams(['real_name' => '管理员详情']); - Route::post('/admin-users', [AdminUserController::class, 'store'])->name('adminApiAdminUsersStore')->setParams(['real_name' => '新增管理员']); - Route::put('/admin-users/{id}', [AdminUserController::class, 'update'])->name('adminApiAdminUsersUpdate')->setParams(['real_name' => '更新管理员']); - Route::delete('/admin-users/{id}', [AdminUserController::class, 'destroy'])->name('adminApiAdminUsersDestroy')->setParams(['real_name' => '删除管理员']); + Route::group('/merchant-api-credentials', function () { + Route::get('', [MerchantApiCredentialController::class, 'index'])->name('adminApiMerchantApiCredentialsIndex')->setParams(['real_name' => '商户 API 凭证列表']); + Route::post('', [MerchantApiCredentialController::class, 'store'])->name('adminApiMerchantApiCredentialsStore')->setParams(['real_name' => '开通商户 API 凭证']); + Route::get('/{id}', [MerchantApiCredentialController::class, 'show'])->name('adminApiMerchantApiCredentialsShow')->setParams(['real_name' => '商户 API 凭证详情']); + Route::put('/{id}', [MerchantApiCredentialController::class, 'update'])->name('adminApiMerchantApiCredentialsUpdate')->setParams(['real_name' => '更新商户 API 凭证']); + Route::delete('/{id}', [MerchantApiCredentialController::class, 'destroy'])->name('adminApiMerchantApiCredentialsDestroy')->setParams(['real_name' => '删除商户 API 凭证']); + }); + Route::group('/merchant-groups', function () { + Route::get('', [MerchantGroupController::class, 'index'])->name('adminApiMerchantGroupsIndex')->setParams(['real_name' => '商户分组列表']); + Route::get('/options', [MerchantGroupController::class, 'options'])->name('adminApiMerchantGroupsOptions')->setParams(['real_name' => '商户分组选项']); + Route::post('', [MerchantGroupController::class, 'store'])->name('adminApiMerchantGroupsStore')->setParams(['real_name' => '新增商户分组']); + Route::get('/{id}', [MerchantGroupController::class, 'show'])->name('adminApiMerchantGroupsShow')->setParams(['real_name' => '商户分组详情']); + Route::put('/{id}', [MerchantGroupController::class, 'update'])->name('adminApiMerchantGroupsUpdate')->setParams(['real_name' => '更新商户分组']); + Route::delete('/{id}', [MerchantGroupController::class, 'destroy'])->name('adminApiMerchantGroupsDestroy')->setParams(['real_name' => '删除商户分组']); + }); - Route::get('/merchant-api-credentials', [MerchantApiCredentialController::class, 'index'])->name('adminApiMerchantApiCredentialsIndex')->setParams(['real_name' => '商户 API 凭证列表']); - Route::get('/merchant-api-credentials/{id}', [MerchantApiCredentialController::class, 'show'])->name('adminApiMerchantApiCredentialsShow')->setParams(['real_name' => '商户 API 凭证详情']); - Route::post('/merchant-api-credentials', [MerchantApiCredentialController::class, 'store'])->name('adminApiMerchantApiCredentialsStore')->setParams(['real_name' => '开通商户 API 凭证']); - Route::put('/merchant-api-credentials/{id}', [MerchantApiCredentialController::class, 'update'])->name('adminApiMerchantApiCredentialsUpdate')->setParams(['real_name' => '更新商户 API 凭证']); - Route::delete('/merchant-api-credentials/{id}', [MerchantApiCredentialController::class, 'destroy'])->name('adminApiMerchantApiCredentialsDestroy')->setParams(['real_name' => '删除商户 API 凭证']); + Route::group('/merchant-policies', function () { + Route::get('', [MerchantPolicyController::class, 'index'])->name('adminApiMerchantPoliciesIndex')->setParams(['real_name' => '商户策略列表']); + Route::post('', [MerchantPolicyController::class, 'store'])->name('adminApiMerchantPoliciesStore')->setParams(['real_name' => '新增商户策略']); + Route::get('/{merchantId}', [MerchantPolicyController::class, 'show'])->name('adminApiMerchantPoliciesShow')->setParams(['real_name' => '商户策略详情']); + Route::put('/{merchantId}', [MerchantPolicyController::class, 'update'])->name('adminApiMerchantPoliciesUpdate')->setParams(['real_name' => '更新商户策略']); + Route::delete('/{merchantId}', [MerchantPolicyController::class, 'destroy'])->name('adminApiMerchantPoliciesDestroy')->setParams(['real_name' => '删除商户策略']); + }); - Route::get('/merchant-groups', [MerchantGroupController::class, 'index'])->name('adminApiMerchantGroupsIndex')->setParams(['real_name' => '商户分组列表']); - Route::get('/merchant-groups/options', [MerchantGroupController::class, 'options'])->name('adminApiMerchantGroupsOptions')->setParams(['real_name' => '商户分组选项']); - Route::get('/merchant-groups/{id}', [MerchantGroupController::class, 'show'])->name('adminApiMerchantGroupsShow')->setParams(['real_name' => '商户分组详情']); - Route::post('/merchant-groups', [MerchantGroupController::class, 'store'])->name('adminApiMerchantGroupsStore')->setParams(['real_name' => '新增商户分组']); - Route::put('/merchant-groups/{id}', [MerchantGroupController::class, 'update'])->name('adminApiMerchantGroupsUpdate')->setParams(['real_name' => '更新商户分组']); - Route::delete('/merchant-groups/{id}', [MerchantGroupController::class, 'destroy'])->name('adminApiMerchantGroupsDestroy')->setParams(['real_name' => '删除商户分组']); + // 支付配置 + Route::group('/payment-types', function () { + Route::get('', [PaymentTypeController::class, 'index'])->name('adminApiPaymentTypesIndex')->setParams(['real_name' => '支付方式列表']); + Route::get('/options', [PaymentTypeController::class, 'options'])->name('adminApiPaymentTypesOptions')->setParams(['real_name' => '支付方式选项']); + Route::post('', [PaymentTypeController::class, 'store'])->name('adminApiPaymentTypesStore')->setParams(['real_name' => '新增支付方式']); + Route::get('/{id}', [PaymentTypeController::class, 'show'])->name('adminApiPaymentTypesShow')->setParams(['real_name' => '支付方式详情']); + Route::put('/{id}', [PaymentTypeController::class, 'update'])->name('adminApiPaymentTypesUpdate')->setParams(['real_name' => '更新支付方式']); + Route::delete('/{id}', [PaymentTypeController::class, 'destroy'])->name('adminApiPaymentTypesDestroy')->setParams(['real_name' => '删除支付方式']); + }); - Route::get('/merchant-policies', [MerchantPolicyController::class, 'index'])->name('adminApiMerchantPoliciesIndex')->setParams(['real_name' => '商户策略列表']); - Route::get('/merchant-policies/{merchantId}', [MerchantPolicyController::class, 'show'])->name('adminApiMerchantPoliciesShow')->setParams(['real_name' => '商户策略详情']); - Route::post('/merchant-policies', [MerchantPolicyController::class, 'store'])->name('adminApiMerchantPoliciesStore')->setParams(['real_name' => '新增商户策略']); - Route::put('/merchant-policies/{merchantId}', [MerchantPolicyController::class, 'update'])->name('adminApiMerchantPoliciesUpdate')->setParams(['real_name' => '更新商户策略']); - Route::delete('/merchant-policies/{merchantId}', [MerchantPolicyController::class, 'destroy'])->name('adminApiMerchantPoliciesDestroy')->setParams(['real_name' => '删除商户策略']); + Route::group('/payment-plugins', function () { + Route::get('', [PaymentPluginController::class, 'index'])->name('adminApiPaymentPluginsIndex')->setParams(['real_name' => '支付插件列表']); + Route::get('/options', [PaymentPluginController::class, 'options'])->name('adminApiPaymentPluginsOptions')->setParams(['real_name' => '支付插件选项']); + Route::get('/select-options', [PaymentPluginController::class, 'selectOptions'])->name('adminApiPaymentPluginsSelectOptions')->setParams(['real_name' => '支付插件选择项']); + Route::get('/channel-options', [PaymentPluginController::class, 'channelOptions'])->name('adminApiPaymentPluginsChannelOptions')->setParams(['real_name' => '支付插件通道选项']); + Route::post('/refresh', [PaymentPluginController::class, 'refresh'])->name('adminApiPaymentPluginsRefresh')->setParams(['real_name' => '刷新支付插件']); + Route::get('/{code}/schema', [PaymentPluginController::class, 'schema'])->name('adminApiPaymentPluginsSchema')->setParams(['real_name' => '支付插件配置结构']); + Route::get('/{code}', [PaymentPluginController::class, 'show'])->name('adminApiPaymentPluginsShow')->setParams(['real_name' => '支付插件详情']); + Route::put('/{code}', [PaymentPluginController::class, 'update'])->name('adminApiPaymentPluginsUpdate')->setParams(['real_name' => '更新支付插件']); + }); - Route::get('/payment-types', [PaymentTypeController::class, 'index'])->name('adminApiPaymentTypesIndex')->setParams(['real_name' => '支付方式列表']); - Route::get('/payment-types/options', [PaymentTypeController::class, 'options'])->name('adminApiPaymentTypesOptions')->setParams(['real_name' => '支付方式选项']); - Route::get('/payment-types/{id}', [PaymentTypeController::class, 'show'])->name('adminApiPaymentTypesShow')->setParams(['real_name' => '支付方式详情']); - Route::post('/payment-types', [PaymentTypeController::class, 'store'])->name('adminApiPaymentTypesStore')->setParams(['real_name' => '新增支付方式']); - Route::put('/payment-types/{id}', [PaymentTypeController::class, 'update'])->name('adminApiPaymentTypesUpdate')->setParams(['real_name' => '更新支付方式']); - Route::delete('/payment-types/{id}', [PaymentTypeController::class, 'destroy'])->name('adminApiPaymentTypesDestroy')->setParams(['real_name' => '删除支付方式']); + Route::group('/payment-plugin-confs', function () { + Route::get('', [PaymentPluginConfController::class, 'index'])->name('adminApiPaymentPluginConfsIndex')->setParams(['real_name' => '支付插件配置列表']); + Route::get('/options', [PaymentPluginConfController::class, 'options'])->name('adminApiPaymentPluginConfsOptions')->setParams(['real_name' => '支付插件配置选项']); + Route::get('/select-options', [PaymentPluginConfController::class, 'selectOptions'])->name('adminApiPaymentPluginConfsSelectOptions')->setParams(['real_name' => '支付插件配置选择项']); + Route::post('', [PaymentPluginConfController::class, 'store'])->name('adminApiPaymentPluginConfsStore')->setParams(['real_name' => '新增支付插件配置']); + Route::get('/{id}', [PaymentPluginConfController::class, 'show'])->name('adminApiPaymentPluginConfsShow')->setParams(['real_name' => '支付插件配置详情']); + Route::put('/{id}', [PaymentPluginConfController::class, 'update'])->name('adminApiPaymentPluginConfsUpdate')->setParams(['real_name' => '更新支付插件配置']); + Route::delete('/{id}', [PaymentPluginConfController::class, 'destroy'])->name('adminApiPaymentPluginConfsDestroy')->setParams(['real_name' => '删除支付插件配置']); + }); - Route::get('/payment-plugins', [PaymentPluginController::class, 'index'])->name('adminApiPaymentPluginsIndex')->setParams(['real_name' => '支付插件列表']); - Route::get('/payment-plugins/options', [PaymentPluginController::class, 'options'])->name('adminApiPaymentPluginsOptions')->setParams(['real_name' => '支付插件选项']); - Route::get('/payment-plugins/select-options', [PaymentPluginController::class, 'selectOptions'])->name('adminApiPaymentPluginsSelectOptions')->setParams(['real_name' => '支付插件选择项']); - Route::get('/payment-plugins/channel-options', [PaymentPluginController::class, 'channelOptions'])->name('adminApiPaymentPluginsChannelOptions')->setParams(['real_name' => '支付插件通道选项']); - Route::get('/payment-plugins/{code}/schema', [PaymentPluginController::class, 'schema'])->name('adminApiPaymentPluginsSchema')->setParams(['real_name' => '支付插件配置结构']); - Route::get('/payment-plugins/{code}', [PaymentPluginController::class, 'show'])->name('adminApiPaymentPluginsShow')->setParams(['real_name' => '支付插件详情']); - Route::post('/payment-plugins/refresh', [PaymentPluginController::class, 'refresh'])->name('adminApiPaymentPluginsRefresh')->setParams(['real_name' => '刷新支付插件']); - Route::put('/payment-plugins/{code}', [PaymentPluginController::class, 'update'])->name('adminApiPaymentPluginsUpdate')->setParams(['real_name' => '更新支付插件']); + Route::group('/payment-channels', function () { + Route::get('', [PaymentChannelController::class, 'index'])->name('adminApiPaymentChannelsIndex')->setParams(['real_name' => '支付通道列表']); + Route::get('/options', [PaymentChannelController::class, 'options'])->name('adminApiPaymentChannelsOptions')->setParams(['real_name' => '支付通道选项']); + Route::get('/select-options', [PaymentChannelController::class, 'selectOptions'])->name('adminApiPaymentChannelsSelectOptions')->setParams(['real_name' => '支付通道选择项']); + Route::get('/route-options', [PaymentChannelController::class, 'routeOptions'])->name('adminApiPaymentChannelsRouteOptions')->setParams(['real_name' => '支付通道路由选项']); + Route::post('', [PaymentChannelController::class, 'store'])->name('adminApiPaymentChannelsStore')->setParams(['real_name' => '新增支付通道']); + Route::post('/{id}/test', [PaymentChannelController::class, 'test'])->name('adminApiPaymentChannelsTest')->setParams(['real_name' => '测试支付通道']); + Route::get('/{id}', [PaymentChannelController::class, 'show'])->name('adminApiPaymentChannelsShow')->setParams(['real_name' => '支付通道详情']); + Route::put('/{id}', [PaymentChannelController::class, 'update'])->name('adminApiPaymentChannelsUpdate')->setParams(['real_name' => '更新支付通道']); + Route::delete('/{id}', [PaymentChannelController::class, 'destroy'])->name('adminApiPaymentChannelsDestroy')->setParams(['real_name' => '删除支付通道']); + }); - Route::get('/payment-plugin-confs', [PaymentPluginConfController::class, 'index'])->name('adminApiPaymentPluginConfsIndex')->setParams(['real_name' => '支付插件配置列表']); - Route::get('/payment-plugin-confs/options', [PaymentPluginConfController::class, 'options'])->name('adminApiPaymentPluginConfsOptions')->setParams(['real_name' => '支付插件配置选项']); - Route::get('/payment-plugin-confs/select-options', [PaymentPluginConfController::class, 'selectOptions'])->name('adminApiPaymentPluginConfsSelectOptions')->setParams(['real_name' => '支付插件配置选择项']); - Route::get('/payment-plugin-confs/{id}', [PaymentPluginConfController::class, 'show'])->name('adminApiPaymentPluginConfsShow')->setParams(['real_name' => '支付插件配置详情']); - Route::post('/payment-plugin-confs', [PaymentPluginConfController::class, 'store'])->name('adminApiPaymentPluginConfsStore')->setParams(['real_name' => '新增支付插件配置']); - Route::put('/payment-plugin-confs/{id}', [PaymentPluginConfController::class, 'update'])->name('adminApiPaymentPluginConfsUpdate')->setParams(['real_name' => '更新支付插件配置']); - Route::delete('/payment-plugin-confs/{id}', [PaymentPluginConfController::class, 'destroy'])->name('adminApiPaymentPluginConfsDestroy')->setParams(['real_name' => '删除支付插件配置']); + Route::group('/payment-poll-groups', function () { + Route::get('', [PaymentPollGroupController::class, 'index'])->name('adminApiPaymentPollGroupsIndex')->setParams(['real_name' => '轮询组列表']); + Route::get('/options', [PaymentPollGroupController::class, 'options'])->name('adminApiPaymentPollGroupsOptions')->setParams(['real_name' => '轮询组选项']); + Route::post('', [PaymentPollGroupController::class, 'store'])->name('adminApiPaymentPollGroupsStore')->setParams(['real_name' => '新增轮询组']); + Route::get('/{id}', [PaymentPollGroupController::class, 'show'])->name('adminApiPaymentPollGroupsShow')->setParams(['real_name' => '轮询组详情']); + Route::put('/{id}', [PaymentPollGroupController::class, 'update'])->name('adminApiPaymentPollGroupsUpdate')->setParams(['real_name' => '更新轮询组']); + Route::delete('/{id}', [PaymentPollGroupController::class, 'destroy'])->name('adminApiPaymentPollGroupsDestroy')->setParams(['real_name' => '删除轮询组']); + }); - Route::get('/payment-channels', [PaymentChannelController::class, 'index'])->name('adminApiPaymentChannelsIndex')->setParams(['real_name' => '支付通道列表']); - Route::get('/payment-channels/options', [PaymentChannelController::class, 'options'])->name('adminApiPaymentChannelsOptions')->setParams(['real_name' => '支付通道选项']); - Route::get('/payment-channels/select-options', [PaymentChannelController::class, 'selectOptions'])->name('adminApiPaymentChannelsSelectOptions')->setParams(['real_name' => '支付通道选择项']); - Route::get('/payment-channels/route-options', [PaymentChannelController::class, 'routeOptions'])->name('adminApiPaymentChannelsRouteOptions')->setParams(['real_name' => '支付通道路由选项']); - Route::get('/payment-channels/{id}', [PaymentChannelController::class, 'show'])->name('adminApiPaymentChannelsShow')->setParams(['real_name' => '支付通道详情']); - Route::post('/payment-channels', [PaymentChannelController::class, 'store'])->name('adminApiPaymentChannelsStore')->setParams(['real_name' => '新增支付通道']); - Route::put('/payment-channels/{id}', [PaymentChannelController::class, 'update'])->name('adminApiPaymentChannelsUpdate')->setParams(['real_name' => '更新支付通道']); - Route::delete('/payment-channels/{id}', [PaymentChannelController::class, 'destroy'])->name('adminApiPaymentChannelsDestroy')->setParams(['real_name' => '删除支付通道']); + Route::group('/payment-poll-group-channels', function () { + Route::get('', [PaymentPollGroupChannelController::class, 'index'])->name('adminApiPaymentPollGroupChannelsIndex')->setParams(['real_name' => '轮询组通道列表']); + Route::post('', [PaymentPollGroupChannelController::class, 'store'])->name('adminApiPaymentPollGroupChannelsStore')->setParams(['real_name' => '新增轮询组通道']); + Route::get('/{id}', [PaymentPollGroupChannelController::class, 'show'])->name('adminApiPaymentPollGroupChannelsShow')->setParams(['real_name' => '轮询组通道详情']); + Route::put('/{id}', [PaymentPollGroupChannelController::class, 'update'])->name('adminApiPaymentPollGroupChannelsUpdate')->setParams(['real_name' => '更新轮询组通道']); + Route::delete('/{id}', [PaymentPollGroupChannelController::class, 'destroy'])->name('adminApiPaymentPollGroupChannelsDestroy')->setParams(['real_name' => '删除轮询组通道']); + }); - Route::get('/payment-poll-groups', [PaymentPollGroupController::class, 'index'])->name('adminApiPaymentPollGroupsIndex')->setParams(['real_name' => '轮询组列表']); - Route::get('/payment-poll-groups/options', [PaymentPollGroupController::class, 'options'])->name('adminApiPaymentPollGroupsOptions')->setParams(['real_name' => '轮询组选项']); - Route::get('/payment-poll-groups/{id}', [PaymentPollGroupController::class, 'show'])->name('adminApiPaymentPollGroupsShow')->setParams(['real_name' => '轮询组详情']); - Route::post('/payment-poll-groups', [PaymentPollGroupController::class, 'store'])->name('adminApiPaymentPollGroupsStore')->setParams(['real_name' => '新增轮询组']); - Route::put('/payment-poll-groups/{id}', [PaymentPollGroupController::class, 'update'])->name('adminApiPaymentPollGroupsUpdate')->setParams(['real_name' => '更新轮询组']); - Route::delete('/payment-poll-groups/{id}', [PaymentPollGroupController::class, 'destroy'])->name('adminApiPaymentPollGroupsDestroy')->setParams(['real_name' => '删除轮询组']); - - Route::get('/payment-poll-group-channels', [PaymentPollGroupChannelController::class, 'index'])->name('adminApiPaymentPollGroupChannelsIndex')->setParams(['real_name' => '轮询组通道列表']); - Route::get('/payment-poll-group-channels/{id}', [PaymentPollGroupChannelController::class, 'show'])->name('adminApiPaymentPollGroupChannelsShow')->setParams(['real_name' => '轮询组通道详情']); - Route::post('/payment-poll-group-channels', [PaymentPollGroupChannelController::class, 'store'])->name('adminApiPaymentPollGroupChannelsStore')->setParams(['real_name' => '新增轮询组通道']); - Route::put('/payment-poll-group-channels/{id}', [PaymentPollGroupChannelController::class, 'update'])->name('adminApiPaymentPollGroupChannelsUpdate')->setParams(['real_name' => '更新轮询组通道']); - Route::delete('/payment-poll-group-channels/{id}', [PaymentPollGroupChannelController::class, 'destroy'])->name('adminApiPaymentPollGroupChannelsDestroy')->setParams(['real_name' => '删除轮询组通道']); - - Route::get('/payment-poll-group-binds', [PaymentPollGroupBindController::class, 'index'])->name('adminApiPaymentPollGroupBindsIndex')->setParams(['real_name' => '轮询组绑定列表']); - Route::get('/payment-poll-group-binds/{id}', [PaymentPollGroupBindController::class, 'show'])->name('adminApiPaymentPollGroupBindsShow')->setParams(['real_name' => '轮询组绑定详情']); - Route::post('/payment-poll-group-binds', [PaymentPollGroupBindController::class, 'store'])->name('adminApiPaymentPollGroupBindsStore')->setParams(['real_name' => '新增轮询组绑定']); - Route::put('/payment-poll-group-binds/{id}', [PaymentPollGroupBindController::class, 'update'])->name('adminApiPaymentPollGroupBindsUpdate')->setParams(['real_name' => '更新轮询组绑定']); - Route::delete('/payment-poll-group-binds/{id}', [PaymentPollGroupBindController::class, 'destroy'])->name('adminApiPaymentPollGroupBindsDestroy')->setParams(['real_name' => '删除轮询组绑定']); + Route::group('/payment-poll-group-binds', function () { + Route::get('', [PaymentPollGroupBindController::class, 'index'])->name('adminApiPaymentPollGroupBindsIndex')->setParams(['real_name' => '轮询组绑定列表']); + Route::post('', [PaymentPollGroupBindController::class, 'store'])->name('adminApiPaymentPollGroupBindsStore')->setParams(['real_name' => '新增轮询组绑定']); + Route::get('/{id}', [PaymentPollGroupBindController::class, 'show'])->name('adminApiPaymentPollGroupBindsShow')->setParams(['real_name' => '轮询组绑定详情']); + Route::put('/{id}', [PaymentPollGroupBindController::class, 'update'])->name('adminApiPaymentPollGroupBindsUpdate')->setParams(['real_name' => '更新轮询组绑定']); + Route::delete('/{id}', [PaymentPollGroupBindController::class, 'destroy'])->name('adminApiPaymentPollGroupBindsDestroy')->setParams(['real_name' => '删除轮询组绑定']); + }); Route::get('/routes/resolve', [RouteController::class, 'resolve'])->name('adminApiRoutesResolve')->setParams(['real_name' => '解析路由']); - Route::get('/channel-daily-stats', [ChannelDailyStatController::class, 'index'])->name('adminApiChannelDailyStatsIndex')->setParams(['real_name' => '渠道日统计列表']); - Route::get('/channel-daily-stats/{id}', [ChannelDailyStatController::class, 'show'])->name('adminApiChannelDailyStatsShow')->setParams(['real_name' => '渠道日统计详情']); + // 文件存储 + Route::group('/file-asset', function () { + Route::get('', [FileRecordController::class, 'index'])->name('adminApiFileRecordIndex')->setParams(['real_name' => '文件列表']); + Route::get('/options', [FileRecordController::class, 'options'])->name('adminApiFileRecordOptions')->setParams(['real_name' => '文件选项']); + Route::post('/upload', [FileRecordController::class, 'upload'])->name('adminApiFileRecordUpload')->setParams(['real_name' => '上传文件']); + Route::post('/import-remote', [FileRecordController::class, 'importRemote'])->name('adminApiFileRecordImportRemote')->setParams(['real_name' => '导入远程文件']); + Route::get('/{id}/preview', [FileRecordController::class, 'preview'])->name('adminApiFileRecordPreview')->setParams(['real_name' => '文件预览']); + Route::get('/{id}/download', [FileRecordController::class, 'download'])->name('adminApiFileRecordDownload')->setParams(['real_name' => '文件下载']); + Route::get('/{id}', [FileRecordController::class, 'show'])->name('adminApiFileRecordShow')->setParams(['real_name' => '文件详情']); + Route::delete('/{id}', [FileRecordController::class, 'destroy'])->name('adminApiFileRecordDestroy')->setParams(['real_name' => '删除文件']); + }); - Route::get('/file-asset/options', [FileRecordController::class, 'options'])->name('adminApiFileRecordOptions')->setParams(['real_name' => '文件选项']); - Route::get('/file-asset', [FileRecordController::class, 'index'])->name('adminApiFileRecordIndex')->setParams(['real_name' => '文件列表']); - Route::post('/file-asset/upload', [FileRecordController::class, 'upload'])->name('adminApiFileRecordUpload')->setParams(['real_name' => '上传文件']); - Route::post('/file-asset/import-remote', [FileRecordController::class, 'importRemote'])->name('adminApiFileRecordImportRemote')->setParams(['real_name' => '导入远程文件']); - Route::get('/file-asset/{id}/preview', [FileRecordController::class, 'preview'])->name('adminApiFileRecordPreview')->setParams(['real_name' => '文件预览']); - Route::get('/file-asset/{id}/download', [FileRecordController::class, 'download'])->name('adminApiFileRecordDownload')->setParams(['real_name' => '文件下载']); - Route::get('/file-asset/{id}', [FileRecordController::class, 'show'])->name('adminApiFileRecordShow')->setParams(['real_name' => '文件详情']); - Route::delete('/file-asset/{id}', [FileRecordController::class, 'destroy'])->name('adminApiFileRecordDestroy')->setParams(['real_name' => '删除文件']); + // 交易订单 + Route::group('/pay-orders', function () { + Route::get('', [PayOrderController::class, 'index'])->name('adminApiPayOrdersIndex')->setParams(['real_name' => '支付订单列表']); + Route::get('/{payNo}/actions', [PayOrderController::class, 'actions'])->name('adminApiPayOrdersActions')->setParams(['real_name' => '支付订单可操作项']); + Route::post('/{payNo}/renotify', [PayOrderController::class, 'renotify'])->name('adminApiPayOrdersRenotify')->setParams(['real_name' => '支付订单重新通知']); + Route::post('/{payNo}/query', [PayOrderController::class, 'activeQuery'])->name('adminApiPayOrdersActiveQuery')->setParams(['real_name' => '支付订单主动查询']); + Route::post('/{payNo}/api-refund', [PayOrderController::class, 'apiRefund'])->name('adminApiPayOrdersApiRefund')->setParams(['real_name' => '支付订单 API 退款']); + Route::post('/{payNo}/manual-refund', [PayOrderController::class, 'manualRefund'])->name('adminApiPayOrdersManualRefund')->setParams(['real_name' => '支付订单手动退款']); + Route::post('/{payNo}/manual-success', [PayOrderController::class, 'manualSuccess'])->name('adminApiPayOrdersManualSuccess')->setParams(['real_name' => '支付订单手动补单']); + Route::post('/{payNo}/freeze', [PayOrderController::class, 'freeze'])->name('adminApiPayOrdersFreeze')->setParams(['real_name' => '支付订单冻结']); + Route::post('/{payNo}/unfreeze', [PayOrderController::class, 'unfreeze'])->name('adminApiPayOrdersUnfreeze')->setParams(['real_name' => '支付订单解冻']); + Route::get('/{payNo}', [PayOrderController::class, 'show'])->name('adminApiPayOrdersShow')->setParams(['real_name' => '支付订单详情']); + }); - Route::get('/pay-orders', [PayOrderController::class, 'index'])->name('adminApiPayOrdersIndex')->setParams(['real_name' => '支付订单列表']); - Route::get('/pay-orders/{payNo}', [PayOrderController::class, 'show'])->name('adminApiPayOrdersShow')->setParams(['real_name' => '支付订单详情']); - Route::get('/refund-orders', [RefundOrderController::class, 'index'])->name('adminApiRefundOrdersIndex')->setParams(['real_name' => '退款订单列表']); - Route::get('/refund-orders/{refundNo}', [RefundOrderController::class, 'show'])->name('adminApiRefundOrdersShow')->setParams(['real_name' => '退款订单详情']); - Route::post('/refund-orders/{refundNo}/retry', [RefundOrderController::class, 'retry'])->name('adminApiRefundOrdersRetry')->setParams(['real_name' => '退款重试']); + Route::group('/refund-orders', function () { + Route::get('', [RefundOrderController::class, 'index'])->name('adminApiRefundOrdersIndex')->setParams(['real_name' => '退款订单列表']); + Route::get('/{refundNo}', [RefundOrderController::class, 'show'])->name('adminApiRefundOrdersShow')->setParams(['real_name' => '退款订单详情']); + Route::post('/{refundNo}/retry', [RefundOrderController::class, 'retry'])->name('adminApiRefundOrdersRetry')->setParams(['real_name' => '退款重试']); + }); - Route::get('/settlement-orders', [SettlementOrderController::class, 'index'])->name('adminApiSettlementOrdersIndex')->setParams(['real_name' => '清算订单列表']); - Route::get('/settlement-orders/{settleNo}', [SettlementOrderController::class, 'show'])->name('adminApiSettlementOrdersShow')->setParams(['real_name' => '清算订单详情']); + Route::group('/settlement-orders', function () { + Route::get('', [SettlementOrderController::class, 'index'])->name('adminApiSettlementOrdersIndex')->setParams(['real_name' => '清算订单列表']); + Route::post('/{settleNo}/complete', [SettlementOrderController::class, 'complete'])->name('adminApiSettlementOrdersComplete')->setParams(['real_name' => '清算订单入账']); + Route::post('/{settleNo}/fail', [SettlementOrderController::class, 'markFailed'])->name('adminApiSettlementOrdersFail')->setParams(['real_name' => '清算订单冲正']); + Route::get('/{settleNo}', [SettlementOrderController::class, 'show'])->name('adminApiSettlementOrdersShow')->setParams(['real_name' => '清算订单详情']); + }); - Route::get('/channel-notify-logs', [ChannelNotifyLogController::class, 'index'])->name('adminApiChannelNotifyLogsIndex')->setParams(['real_name' => '渠道通知日志列表']); - Route::get('/channel-notify-logs/{id}', [ChannelNotifyLogController::class, 'show'])->name('adminApiChannelNotifyLogsShow')->setParams(['real_name' => '渠道通知日志详情']); + // 运维与日志 + Route::group('/channel-daily-stats', function () { + Route::get('', [ChannelDailyStatController::class, 'index'])->name('adminApiChannelDailyStatsIndex')->setParams(['real_name' => '渠道日统计列表']); + Route::get('/{id}', [ChannelDailyStatController::class, 'show'])->name('adminApiChannelDailyStatsShow')->setParams(['real_name' => '渠道日统计详情']); + }); - Route::get('/pay-callback-logs', [PayCallbackLogController::class, 'index'])->name('adminApiPayCallbackLogsIndex')->setParams(['real_name' => '支付回调日志列表']); - Route::get('/pay-callback-logs/{id}', [PayCallbackLogController::class, 'show'])->name('adminApiPayCallbackLogsShow')->setParams(['real_name' => '支付回调日志详情']); + Route::group('/channel-notify-logs', function () { + Route::get('', [ChannelNotifyLogController::class, 'index'])->name('adminApiChannelNotifyLogsIndex')->setParams(['real_name' => '渠道通知日志列表']); + Route::get('/{id}', [ChannelNotifyLogController::class, 'show'])->name('adminApiChannelNotifyLogsShow')->setParams(['real_name' => '渠道通知日志详情']); + }); - Route::get('/merchant-notify-tasks', [MerchantNotifyTaskController::class, 'index'])->name('adminApiMerchantNotifyTasksIndex')->setParams(['real_name' => '商户通知任务列表']); - Route::get('/merchant-notify-tasks/{notifyNo}', [MerchantNotifyTaskController::class, 'show'])->name('adminApiMerchantNotifyTasksShow')->setParams(['real_name' => '商户通知任务详情']); - Route::post('/merchant-notify-tasks/{notifyNo}/retry', [MerchantNotifyTaskController::class, 'retry'])->name('adminApiMerchantNotifyTasksRetry')->setParams(['real_name' => '商户通知任务重试']); + Route::group('/pay-callback-logs', function () { + Route::get('', [PayCallbackLogController::class, 'index'])->name('adminApiPayCallbackLogsIndex')->setParams(['real_name' => '支付回调日志列表']); + Route::get('/{id}', [PayCallbackLogController::class, 'show'])->name('adminApiPayCallbackLogsShow')->setParams(['real_name' => '支付回调日志详情']); + }); - Route::get('/merchant-accounts', [MerchantAccountController::class, 'index'])->name('adminApiMerchantAccountsIndex')->setParams(['real_name' => '资金账户列表']); - Route::get('/merchant-accounts/summary', [MerchantAccountController::class, 'summary'])->name('adminApiMerchantAccountsSummary')->setParams(['real_name' => '资金账户总览']); - Route::get('/merchant-accounts/{id}', [MerchantAccountController::class, 'show'])->name('adminApiMerchantAccountsShow')->setParams(['real_name' => '资金账户详情']); + Route::group('/merchant-notify-tasks', function () { + Route::get('', [MerchantNotifyTaskController::class, 'index'])->name('adminApiMerchantNotifyTasksIndex')->setParams(['real_name' => '商户通知任务列表']); + Route::get('/{notifyNo}', [MerchantNotifyTaskController::class, 'show'])->name('adminApiMerchantNotifyTasksShow')->setParams(['real_name' => '商户通知任务详情']); + Route::post('/{notifyNo}/retry', [MerchantNotifyTaskController::class, 'retry'])->name('adminApiMerchantNotifyTasksRetry')->setParams(['real_name' => '商户通知任务重试']); + }); - Route::get('/account-ledgers', [MerchantAccountLedgerController::class, 'index'])->name('adminApiAccountLedgersIndex')->setParams(['real_name' => '资金流水列表']); - Route::get('/account-ledgers/{id}', [MerchantAccountLedgerController::class, 'show'])->name('adminApiAccountLedgersShow')->setParams(['real_name' => '资金流水详情']); + // 资金账户 + Route::group('/merchant-accounts', function () { + Route::get('', [MerchantAccountController::class, 'index'])->name('adminApiMerchantAccountsIndex')->setParams(['real_name' => '资金账户列表']); + Route::get('/summary', [MerchantAccountController::class, 'summary'])->name('adminApiMerchantAccountsSummary')->setParams(['real_name' => '资金账户总览']); + Route::get('/{id}', [MerchantAccountController::class, 'show'])->name('adminApiMerchantAccountsShow')->setParams(['real_name' => '资金账户详情']); + }); - Route::get('/system/menu-tree', [SystemController::class, 'menuTree'])->name('adminApiMenuTree')->setParams(['real_name' => '菜单树']); - Route::get('/system/dict-items', [SystemController::class, 'dictItems'])->name('adminApiDictItems')->setParams(['real_name' => '字典项']); + Route::group('/account-ledgers', function () { + Route::get('', [MerchantAccountLedgerController::class, 'index'])->name('adminApiAccountLedgersIndex')->setParams(['real_name' => '资金流水列表']); + Route::get('/{id}', [MerchantAccountLedgerController::class, 'show'])->name('adminApiAccountLedgersShow')->setParams(['real_name' => '资金流水详情']); + }); - Route::get('/system-config-pages', [SystemConfigPageController::class, 'index'])->name('adminApiSystemConfigPagesIndex')->setParams(['real_name' => '系统配置页面列表']); - Route::get('/system-config-pages/{groupCode}', [SystemConfigPageController::class, 'show'])->name('adminApiSystemConfigPagesShow')->setParams(['real_name' => '系统配置页面详情']); - Route::post('/system-config-pages/{groupCode}', [SystemConfigPageController::class, 'store'])->name('adminApiSystemConfigPagesStore')->setParams(['real_name' => '保存系统配置页面']); + // 系统管理 + Route::group('/admin-users', function () { + Route::get('', [AdminUserController::class, 'index'])->name('adminApiAdminUsersIndex')->setParams(['real_name' => '管理员列表']); + Route::post('', [AdminUserController::class, 'store'])->name('adminApiAdminUsersStore')->setParams(['real_name' => '新增管理员']); + Route::get('/{id}', [AdminUserController::class, 'show'])->name('adminApiAdminUsersShow')->setParams(['real_name' => '管理员详情']); + Route::put('/{id}', [AdminUserController::class, 'update'])->name('adminApiAdminUsersUpdate')->setParams(['real_name' => '更新管理员']); + Route::delete('/{id}', [AdminUserController::class, 'destroy'])->name('adminApiAdminUsersDestroy')->setParams(['real_name' => '删除管理员']); + }); + Route::group('/system', function () { + Route::get('/menu-tree', [SystemController::class, 'menuTree'])->name('adminApiMenuTree')->setParams(['real_name' => '菜单树']); + Route::get('/dict-items', [SystemController::class, 'dictItems'])->name('adminApiDictItems')->setParams(['real_name' => '字典项']); + }); + + Route::group('/system-config-pages', function () { + Route::get('', [SystemConfigPageController::class, 'index'])->name('adminApiSystemConfigPagesIndex')->setParams(['real_name' => '系统配置页面列表']); + Route::get('/{groupCode}', [SystemConfigPageController::class, 'show'])->name('adminApiSystemConfigPagesShow')->setParams(['real_name' => '系统配置页面详情']); + Route::post('/{groupCode}', [SystemConfigPageController::class, 'store'])->name('adminApiSystemConfigPagesStore')->setParams(['real_name' => '保存系统配置页面']); + }); })->middleware([AdminAuthMiddleware::class]); })->middleware([Cors::class]); diff --git a/app/route/api.php b/app/route/api.php index 686dfc2..13bdca6 100644 --- a/app/route/api.php +++ b/app/route/api.php @@ -5,7 +5,9 @@ use app\common\middleware\Cors; use app\http\api\controller\cashier\CashierController; use app\http\api\controller\epay\EpayV1Controller; use app\http\api\controller\epay\EpayV2Controller; +use app\http\api\controller\system\SystemPublicConfigController; +// 收银台项目:页面路由 $serveCashierApp = static function () { $indexPath = public_path('cashier/index.html'); if (!is_file($indexPath)) { @@ -17,20 +19,6 @@ $serveCashierApp = static function () { ]); }; -// ePay V1 旧版接口 -Route::group('', function () { - Route::any('/submit.php', [EpayV1Controller::class, 'submit'])->name('epayV1Submit')->setParams(['real_name' => 'ePay V1 页面跳转支付']); - Route::post('/mapi.php', [EpayV1Controller::class, 'mapi'])->name('epayV1Mapi')->setParams(['real_name' => 'ePay V1 接口支付']); - Route::any('/api.php', [EpayV1Controller::class, 'api'])->name('epayV1Api')->setParams(['real_name' => 'ePay V1 标准 API']); -})->middleware([Cors::class]); - -// 收银台路由 -Route::group('/api/cashier', function () { - Route::get('/context', [CashierController::class, 'context'])->name('cashierContext')->setParams(['real_name' => '收银台上下文']); - Route::post('/confirm', [CashierController::class, 'confirm'])->name('cashierConfirm')->setParams(['real_name' => '收银台确认支付']); - Route::get('/pay-order', [CashierController::class, 'payOrder'])->name('cashierPayOrder')->setParams(['real_name' => '收银台支付单详情']); -})->middleware([Cors::class]); - Route::group('/cashier', function () use ($serveCashierApp) { Route::get('', $serveCashierApp)->name('cashierIndex')->setParams(['real_name' => '收银台首页']); Route::any('/{bizNo:.+}', $serveCashierApp)->name('cashierDetail')->setParams(['real_name' => '收银台详情页']); @@ -41,9 +29,25 @@ Route::group('/payment', function () use ($serveCashierApp) { Route::any('/{path:.+}', $serveCashierApp)->name('paymentDetail')->setParams(['real_name' => '支付页详情']); }); -// ePay V2 新版接口 +// 收银台项目:接口路由 +Route::group('/api/cashier', function () { + Route::get('/config', [SystemPublicConfigController::class, 'cashier'])->name('cashierPublicConfig')->setParams(['real_name' => '收银台公开配置']); + Route::get('/context', [CashierController::class, 'context'])->name('cashierContext')->setParams(['real_name' => '收银台上下文']); + Route::post('/confirm', [CashierController::class, 'confirm'])->name('cashierConfirm')->setParams(['real_name' => '收银台确认支付']); + Route::get('/pay-order', [CashierController::class, 'payOrder'])->name('cashierPayOrder')->setParams(['real_name' => '收银台支付单详情']); + Route::get('/pay-order-status', [CashierController::class, 'payOrderStatus'])->name('cashierPayOrderStatus')->setParams(['real_name' => '收银台支付单状态']); +})->middleware([Cors::class]); + +// 开放支付:ePay V1 兼容入口 +Route::group('', function () { + Route::any('/submit.php', [EpayV1Controller::class, 'submit'])->name('epayV1Submit')->setParams(['real_name' => 'ePay V1 页面跳转支付']); + Route::post('/mapi.php', [EpayV1Controller::class, 'mapi'])->name('epayV1Mapi')->setParams(['real_name' => 'ePay V1 接口支付']); + Route::any('/api.php', [EpayV1Controller::class, 'api'])->name('epayV1Api')->setParams(['real_name' => 'ePay V1 标准 API']); +})->middleware([Cors::class]); + +// 开放支付:ePay V2 标准接口 Route::group('/api', function () { - // 支付模块 + // 支付订单 Route::group('/pay', function () { // 文档约定是 POST,同时兼容旧版 SDK `getPayLink()` 生成的 GET 请求。 Route::any('/submit', [EpayV2Controller::class, 'submit'])->name('epayV2PaySubmit')->setParams(['real_name' => 'ePay V2 页面跳转支付']); @@ -52,16 +56,17 @@ Route::group('/api', function () { Route::post('/refund', [EpayV2Controller::class, 'refund'])->name('epayV2PayRefund')->setParams(['real_name' => 'ePay V2 退款']); Route::post('/refundquery', [EpayV2Controller::class, 'refundQuery'])->name('epayV2PayRefundQuery')->setParams(['real_name' => 'ePay V2 退款查询']); Route::post('/close', [EpayV2Controller::class, 'close'])->name('epayV2PayClose')->setParams(['real_name' => 'ePay V2 关闭订单']); + Route::any('/{chanId:\d+}/notify', [EpayV2Controller::class, 'channelNotify'])->name('epayChannelNotify')->setParams(['real_name' => '支付通道通知']); Route::any('/{payNo}/callback', [EpayV2Controller::class, 'callback'])->name('epayPayCallback')->setParams(['real_name' => '支付渠道回调']); }); - // 商户模块 + // 商户查询 Route::group('/merchant', function () { Route::post('/info', [EpayV2Controller::class, 'merchantInfo'])->name('epayV2MerchantInfo')->setParams(['real_name' => 'ePay V2 商户信息']); Route::post('/orders', [EpayV2Controller::class, 'merchantOrders'])->name('epayV2MerchantOrders')->setParams(['real_name' => 'ePay V2 商户订单']); }); - // 转账模块 + // 转账 Route::group('/transfer', function () { Route::post('/submit', [EpayV2Controller::class, 'transferSubmit'])->name('epayV2TransferSubmit')->setParams(['real_name' => 'ePay V2 转账提交']); Route::post('/query', [EpayV2Controller::class, 'transferQuery'])->name('epayV2TransferQuery')->setParams(['real_name' => 'ePay V2 转账查询']); diff --git a/app/route/mer.php b/app/route/mer.php index 9927107..6ee3210 100644 --- a/app/route/mer.php +++ b/app/route/mer.php @@ -2,56 +2,103 @@ use Webman\Route; use app\common\middleware\Cors; -use app\http\mer\controller\system\AuthController; -use app\http\mer\controller\merchant\MerchantPortalController; -use app\http\mer\controller\trade\RefundOrderController; -use app\http\mer\controller\trade\PayOrderController; -use app\http\mer\controller\system\SystemController; use app\http\mer\controller\file\FileRecordController; +use app\http\mer\controller\merchant\MerchantPortalController; +use app\http\mer\controller\system\AuthController; +use app\http\mer\controller\system\SystemController; +use app\http\mer\controller\trade\PayOrderController; +use app\http\mer\controller\trade\RefundOrderController; use app\http\mer\middleware\MerchantAuthMiddleware; -Route::any('/mer[/{path:.+}]', function () { - return view('/public/mer/index'); -}); +$serveMerchantApp = static function () { + $indexPath = public_path('mer/index.html'); + if (!is_file($indexPath)) { + return response('Merchant page not found', 404); + } + return response(file_get_contents($indexPath), 200, [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); +}; + +// 商户后台项目:页面路由 +Route::any('/mer', $serveMerchantApp); +Route::any('/mer/', $serveMerchantApp); +Route::any('/mer/{path:.+}', $serveMerchantApp); + +// 商户后台项目:接口路由 Route::group('/merapi', function () { + // 公开接口 Route::post('/login', [AuthController::class, 'login'])->name('merchantApiAuthLogin')->setParams(['real_name' => '商户登录']); + Route::get('/system/public-config', [SystemController::class, 'publicConfig'])->name('merchantApiSystemPublicConfig')->setParams(['real_name' => '商户后台公开配置']); Route::group('', function () { + // 会话与当前账号 Route::post('/logout', [AuthController::class, 'logout'])->name('merchantApiAuthLogout')->setParams(['real_name' => '退出登录']); Route::get('/user/profile', [AuthController::class, 'profile'])->name('merchantApiUserProfile')->setParams(['real_name' => '当前登录账号']); - Route::get('/merchant/profile', [MerchantPortalController::class, 'profile'])->name('merchantApiPortalProfile')->setParams(['real_name' => '商户资料']); - Route::put('/merchant/profile', [MerchantPortalController::class, 'updateProfile'])->name('merchantApiPortalProfileUpdate')->setParams(['real_name' => '更新商户资料']); - Route::post('/merchant/change-password', [MerchantPortalController::class, 'changePassword'])->name('merchantApiPortalChangePassword')->setParams(['real_name' => '修改登录密码']); - Route::get('/my-channels', [MerchantPortalController::class, 'myChannels'])->name('merchantApiPortalMyChannels')->setParams(['real_name' => '我的通道']); - Route::get('/my-channels/create-meta', [MerchantPortalController::class, 'channelCreateMeta'])->name('merchantApiPortalChannelCreateMeta')->setParams(['real_name' => '商户通道配置元数据']); - Route::post('/my-channels', [MerchantPortalController::class, 'createChannel'])->name('merchantApiPortalChannelCreate')->setParams(['real_name' => '新增商户通道']); - Route::put('/my-channels/{id}', [MerchantPortalController::class, 'updateChannel'])->name('merchantApiPortalChannelUpdate')->setParams(['real_name' => '修改商户通道']); - Route::delete('/my-channels/{id}', [MerchantPortalController::class, 'deleteChannel'])->name('merchantApiPortalChannelDelete')->setParams(['real_name' => '删除商户通道']); - Route::get('/plugin-configs', [MerchantPortalController::class, 'pluginConfigs'])->name('merchantApiPortalPluginConfigs')->setParams(['real_name' => '商户插件配置']); - Route::get('/plugin-configs/options', [MerchantPortalController::class, 'pluginConfigOptions'])->name('merchantApiPortalPluginConfigOptions')->setParams(['real_name' => '商户插件配置选项']); - Route::post('/plugin-configs', [MerchantPortalController::class, 'createPluginConfig'])->name('merchantApiPortalPluginConfigCreate')->setParams(['real_name' => '新增商户插件配置']); - Route::put('/plugin-configs/{id}', [MerchantPortalController::class, 'updatePluginConfig'])->name('merchantApiPortalPluginConfigUpdate')->setParams(['real_name' => '修改商户插件配置']); - Route::delete('/plugin-configs/{id}', [MerchantPortalController::class, 'deletePluginConfig'])->name('merchantApiPortalPluginConfigDelete')->setParams(['real_name' => '删除商户插件配置']); + + // 商户资料 + Route::group('/merchant', function () { + Route::get('/profile', [MerchantPortalController::class, 'profile'])->name('merchantApiPortalProfile')->setParams(['real_name' => '商户资料']); + Route::put('/profile', [MerchantPortalController::class, 'updateProfile'])->name('merchantApiPortalProfileUpdate')->setParams(['real_name' => '更新商户资料']); + Route::post('/change-password', [MerchantPortalController::class, 'changePassword'])->name('merchantApiPortalChangePassword')->setParams(['real_name' => '修改登录密码']); + }); + + // 通道与插件配置 + Route::group('/my-channels', function () { + Route::get('', [MerchantPortalController::class, 'myChannels'])->name('merchantApiPortalMyChannels')->setParams(['real_name' => '我的通道']); + Route::get('/create-meta', [MerchantPortalController::class, 'channelCreateMeta'])->name('merchantApiPortalChannelCreateMeta')->setParams(['real_name' => '商户通道配置元数据']); + Route::post('', [MerchantPortalController::class, 'createChannel'])->name('merchantApiPortalChannelCreate')->setParams(['real_name' => '新增商户通道']); + Route::put('/{id}', [MerchantPortalController::class, 'updateChannel'])->name('merchantApiPortalChannelUpdate')->setParams(['real_name' => '修改商户通道']); + Route::delete('/{id}', [MerchantPortalController::class, 'deleteChannel'])->name('merchantApiPortalChannelDelete')->setParams(['real_name' => '删除商户通道']); + }); + + Route::group('/plugin-configs', function () { + Route::get('', [MerchantPortalController::class, 'pluginConfigs'])->name('merchantApiPortalPluginConfigs')->setParams(['real_name' => '商户插件配置']); + Route::get('/options', [MerchantPortalController::class, 'pluginConfigOptions'])->name('merchantApiPortalPluginConfigOptions')->setParams(['real_name' => '商户插件配置选项']); + Route::post('', [MerchantPortalController::class, 'createPluginConfig'])->name('merchantApiPortalPluginConfigCreate')->setParams(['real_name' => '新增商户插件配置']); + Route::put('/{id}', [MerchantPortalController::class, 'updatePluginConfig'])->name('merchantApiPortalPluginConfigUpdate')->setParams(['real_name' => '修改商户插件配置']); + Route::delete('/{id}', [MerchantPortalController::class, 'deletePluginConfig'])->name('merchantApiPortalPluginConfigDelete')->setParams(['real_name' => '删除商户插件配置']); + }); + Route::get('/payment-plugins/{code}/schema', [MerchantPortalController::class, 'pluginSchema'])->name('merchantApiPortalPluginSchema')->setParams(['real_name' => '商户插件配置结构']); - Route::post('/file-asset/upload', [FileRecordController::class, 'upload'])->name('merchantApiFileRecordUpload')->setParams(['real_name' => '上传文件']); - Route::get('/file-asset/{id}/preview', [FileRecordController::class, 'preview'])->name('merchantApiFileRecordPreview')->setParams(['real_name' => '文件预览']); - Route::get('/file-asset/{id}/download', [FileRecordController::class, 'download'])->name('merchantApiFileRecordDownload')->setParams(['real_name' => '文件下载']); Route::get('/route-preview', [MerchantPortalController::class, 'routePreview'])->name('merchantApiPortalRoutePreview')->setParams(['real_name' => '路由解析']); - Route::get('/api-credential', [MerchantPortalController::class, 'apiCredential'])->name('merchantApiPortalCredential')->setParams(['real_name' => '商户 API 凭证']); - Route::post('/api-credential/issue-credential', [MerchantPortalController::class, 'issueCredential'])->name('merchantApiPortalIssueCredential')->setParams(['real_name' => '生成或重置商户 API 凭证']); - Route::get('/settlement-records', [MerchantPortalController::class, 'settlementRecords'])->name('merchantApiPortalSettlementRecords')->setParams(['real_name' => '清算记录']); - Route::get('/settlement-records/{settleNo}', [MerchantPortalController::class, 'settlementRecordShow'])->name('merchantApiPortalSettlementRecordShow')->setParams(['real_name' => '清算记录详情']); + + // 文件 + Route::group('/file-asset', function () { + Route::post('/upload', [FileRecordController::class, 'upload'])->name('merchantApiFileRecordUpload')->setParams(['real_name' => '上传文件']); + Route::get('/{id}/preview', [FileRecordController::class, 'preview'])->name('merchantApiFileRecordPreview')->setParams(['real_name' => '文件预览']); + Route::get('/{id}/download', [FileRecordController::class, 'download'])->name('merchantApiFileRecordDownload')->setParams(['real_name' => '文件下载']); + }); + + // API 凭证 + Route::group('/api-credential', function () { + Route::get('', [MerchantPortalController::class, 'apiCredential'])->name('merchantApiPortalCredential')->setParams(['real_name' => '商户 API 凭证']); + Route::post('/issue-credential', [MerchantPortalController::class, 'issueCredential'])->name('merchantApiPortalIssueCredential')->setParams(['real_name' => '生成或重置商户 API 凭证']); + }); + + // 资金与清算 + Route::group('/settlement-records', function () { + Route::get('', [MerchantPortalController::class, 'settlementRecords'])->name('merchantApiPortalSettlementRecords')->setParams(['real_name' => '清算记录']); + Route::get('/{settleNo}', [MerchantPortalController::class, 'settlementRecordShow'])->name('merchantApiPortalSettlementRecordShow')->setParams(['real_name' => '清算记录详情']); + }); + Route::get('/withdrawable-balance', [MerchantPortalController::class, 'withdrawableBalance'])->name('merchantApiPortalWithdrawableBalance')->setParams(['real_name' => '可提现余额']); Route::get('/balance-flows', [MerchantPortalController::class, 'balanceFlows'])->name('merchantApiPortalBalanceFlows')->setParams(['real_name' => '资金流水']); - Route::get('/pay-orders', [PayOrderController::class, 'index'])->name('merchantApiPayOrdersIndex')->setParams(['real_name' => '支付订单']); - Route::get('/refund-orders', [RefundOrderController::class, 'index'])->name('merchantApiRefundOrdersIndex')->setParams(['real_name' => '退款订单']); - Route::get('/refund-orders/{refundNo}', [RefundOrderController::class, 'show'])->name('merchantApiRefundOrdersShow')->setParams(['real_name' => '退款订单详情']); - Route::post('/refund-orders/{refundNo}/retry', [RefundOrderController::class, 'retry']) - ->name('merchantApiRefundOrdersRetry') - ->setParams(['real_name' => '退款重试']); - Route::get('/system/menu-tree', [SystemController::class, 'menuTree'])->name('merchantApiMenuTree')->setParams(['real_name' => '菜单树']); - Route::get('/system/dict-items', [SystemController::class, 'dictItems'])->name('merchantApiDictItems')->setParams(['real_name' => '字典项']); + // 交易订单 + Route::get('/pay-orders', [PayOrderController::class, 'index'])->name('merchantApiPayOrdersIndex')->setParams(['real_name' => '支付订单']); + + Route::group('/refund-orders', function () { + Route::get('', [RefundOrderController::class, 'index'])->name('merchantApiRefundOrdersIndex')->setParams(['real_name' => '退款订单']); + Route::get('/{refundNo}', [RefundOrderController::class, 'show'])->name('merchantApiRefundOrdersShow')->setParams(['real_name' => '退款订单详情']); + Route::post('/{refundNo}/retry', [RefundOrderController::class, 'retry'])->name('merchantApiRefundOrdersRetry')->setParams(['real_name' => '退款重试']); + }); + + // 系统 + Route::group('/system', function () { + Route::get('/menu-tree', [SystemController::class, 'menuTree'])->name('merchantApiMenuTree')->setParams(['real_name' => '菜单树']); + Route::get('/dict-items', [SystemController::class, 'dictItems'])->name('merchantApiDictItems')->setParams(['real_name' => '字典项']); + }); })->middleware([MerchantAuthMiddleware::class]); })->middleware([Cors::class]); diff --git a/app/service/account/funds/MerchantAccountCommandService.php b/app/service/account/funds/MerchantAccountCommandService.php index b028993..bb69bac 100644 --- a/app/service/account/funds/MerchantAccountCommandService.php +++ b/app/service/account/funds/MerchantAccountCommandService.php @@ -3,6 +3,7 @@ namespace app\service\account\funds; use app\common\base\BaseService; +use app\common\constant\FundFreezeConstant; use app\common\constant\LedgerConstant; use app\exception\BalanceInsufficientException; use app\exception\ConflictException; @@ -10,6 +11,7 @@ use app\exception\ValidationException; use app\model\merchant\MerchantAccount; use app\model\merchant\MerchantAccountLedger; use app\repository\account\balance\MerchantAccountRepository; +use app\repository\account\freeze\MerchantFundFreezeRepository; use app\repository\account\ledger\MerchantAccountLedgerRepository; /** @@ -18,6 +20,7 @@ use app\repository\account\ledger\MerchantAccountLedgerRepository; * 只负责账户创建、冻结、扣减、释放和入账等资金变更。 * * @property MerchantAccountRepository $accountRepository 账户仓库 + * @property MerchantFundFreezeRepository $fundFreezeRepository 资金冻结仓库 * @property MerchantAccountLedgerRepository $ledgerRepository 流水仓库 */ class MerchantAccountCommandService extends BaseService @@ -26,11 +29,13 @@ class MerchantAccountCommandService extends BaseService * 构造方法。 * * @param MerchantAccountRepository $accountRepository 账户仓库 + * @param MerchantFundFreezeRepository $fundFreezeRepository 资金冻结仓库 * @param MerchantAccountLedgerRepository $ledgerRepository 流水仓库 * @return void */ public function __construct( protected MerchantAccountRepository $accountRepository, + protected MerchantFundFreezeRepository $fundFreezeRepository, protected MerchantAccountLedgerRepository $ledgerRepository ) { } @@ -109,44 +114,41 @@ class MerchantAccountCommandService extends BaseService */ public function freezeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger { - $this->assertPositiveAmount($amount); - if ($idempotencyKey === '') { - throw new ValidationException('幂等键不能为空'); - } + return $this->freezeAvailableByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_PAY_FREEZE, + $extJson['remark'] ?? '余额冻结', + $extJson, + $traceNo + ); + } - if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { - $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_FREEZE, $bizNo, $amount, LedgerConstant::DIRECTION_OUT); - return $existing; - } - - $account = $this->ensureAccountInCurrentTransaction($merchantId); - if ((int) $account->available_balance < $amount) { - throw new BalanceInsufficientException($merchantId, $amount, (int) $account->available_balance); - } - - $availableBefore = (int) $account->available_balance; - $frozenBefore = (int) $account->frozen_balance; - - $account->available_balance = $availableBefore - $amount; - $account->frozen_balance = $frozenBefore + $amount; - $account->save(); - - return $this->createLedger([ - 'merchant_id' => $merchantId, - 'biz_type' => LedgerConstant::BIZ_TYPE_PAY_FREEZE, - 'biz_no' => $bizNo, - 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), - 'event_type' => LedgerConstant::EVENT_TYPE_CREATE, - 'direction' => LedgerConstant::DIRECTION_OUT, - 'amount' => $amount, - 'available_before' => $availableBefore, - 'available_after' => (int) $account->available_balance, - 'frozen_before' => $frozenBefore, - 'frozen_after' => (int) $account->frozen_balance, - 'idempotency_key' => $idempotencyKey, - 'remark' => $extJson['remark'] ?? '余额冻结', - 'ext_json' => $extJson, - ]); + /** + * 在当前事务中冻结风控资金。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function freezeRiskAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->freezeAvailableByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_RISK_FREEZE, + $extJson['remark'] ?? '风控资金冻结', + $extJson, + $traceNo + ); } /** @@ -206,6 +208,8 @@ class MerchantAccountCommandService extends BaseService $account->frozen_balance = $frozenBefore - $amount; $account->save(); + $this->reduceFundFreezeRecordIfNeeded($merchantId, $amount, $bizNo, LedgerConstant::BIZ_TYPE_PAY_DEDUCT, $extJson); + return $this->createLedger([ 'merchant_id' => $merchantId, 'biz_type' => LedgerConstant::BIZ_TYPE_PAY_DEDUCT, @@ -220,7 +224,6 @@ class MerchantAccountCommandService extends BaseService 'frozen_after' => (int) $account->frozen_balance, 'idempotency_key' => $idempotencyKey, 'remark' => $extJson['remark'] ?? '余额扣减', - 'ext_json' => $extJson, ]); } @@ -256,48 +259,41 @@ class MerchantAccountCommandService extends BaseService */ public function releaseFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger { - $this->assertPositiveAmount($amount); - if ($idempotencyKey === '') { - throw new ValidationException('幂等键不能为空'); - } + return $this->releaseFrozenByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_PAY_RELEASE, + $extJson['remark'] ?? '冻结余额释放', + $extJson, + $traceNo + ); + } - if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { - $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_RELEASE, $bizNo, $amount, LedgerConstant::DIRECTION_IN); - return $existing; - } - - $account = $this->ensureAccountInCurrentTransaction($merchantId); - if ((int) $account->frozen_balance < $amount) { - throw new ValidationException('冻结余额不足', [ - 'merchant_id' => $merchantId, - 'amount' => $amount, - 'frozen_balance' => (int) $account->frozen_balance, - ]); - } - - $availableBefore = (int) $account->available_balance; - $frozenBefore = (int) $account->frozen_balance; - - $account->available_balance = $availableBefore + $amount; - $account->frozen_balance = $frozenBefore - $amount; - $account->save(); - - return $this->createLedger([ - 'merchant_id' => $merchantId, - 'biz_type' => LedgerConstant::BIZ_TYPE_PAY_RELEASE, - 'biz_no' => $bizNo, - 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), - 'event_type' => LedgerConstant::EVENT_TYPE_REVERSE, - 'direction' => LedgerConstant::DIRECTION_IN, - 'amount' => $amount, - 'available_before' => $availableBefore, - 'available_after' => (int) $account->available_balance, - 'frozen_before' => $frozenBefore, - 'frozen_after' => (int) $account->frozen_balance, - 'idempotency_key' => $idempotencyKey, - 'remark' => $extJson['remark'] ?? '冻结余额释放', - 'ext_json' => $extJson, - ]); + /** + * 在当前事务中释放风控冻结资金。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function releaseRiskFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->releaseFrozenByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_RISK_RELEASE, + $extJson['remark'] ?? '风控冻结释放', + $extJson, + $traceNo + ); } /** @@ -363,7 +359,6 @@ class MerchantAccountCommandService extends BaseService 'frozen_after' => (int) $account->frozen_balance, 'idempotency_key' => $idempotencyKey, 'remark' => $extJson['remark'] ?? '清算入账', - 'ext_json' => $extJson, ]); } @@ -400,13 +395,287 @@ class MerchantAccountCommandService extends BaseService */ public function debitAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger { + return $this->debitAvailableByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_REFUND_REVERSE, + LedgerConstant::EVENT_TYPE_REVERSE, + $extJson['remark'] ?? '余额冲减', + $extJson, + $traceNo + ); + } + + /** + * 在当前事务中扣减支付平台服务费。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function debitPayFeeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->debitAvailableByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_PAY_DEDUCT, + LedgerConstant::EVENT_TYPE_SUCCESS, + $extJson['remark'] ?? '自收通道服务费扣减', + $extJson, + $traceNo + ); + } + + /** + * 在当前事务中扣减转账本金。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function debitTransferAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->debitAvailableByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_TRANSFER_DEDUCT, + LedgerConstant::EVENT_TYPE_CREATE, + $extJson['remark'] ?? '转账本金扣减', + $extJson, + $traceNo + ); + } + + /** + * 在当前事务中扣减转账手续费。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function debitTransferFeeInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->debitAvailableByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_TRANSFER_FEE, + LedgerConstant::EVENT_TYPE_CREATE, + $extJson['remark'] ?? '转账手续费扣减', + $extJson, + $traceNo + ); + } + + /** + * 在当前事务中释放失败转账已扣金额。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function releaseTransferAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->creditAvailableByBizTypeInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + $idempotencyKey, + LedgerConstant::BIZ_TYPE_TRANSFER_RELEASE, + LedgerConstant::EVENT_TYPE_REVERSE, + $extJson['remark'] ?? '转账失败释放', + $extJson, + $traceNo + ); + } + + /** + * 按指定业务类型冻结可用余额。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param int $bizType 流水业务类型 + * @param string $remark 流水备注 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + private function freezeAvailableByBizTypeInCurrentTransaction( + int $merchantId, + int $amount, + string $bizNo, + string $idempotencyKey, + int $bizType, + string $remark, + array $extJson = [], + string $traceNo = '' + ): MerchantAccountLedger { $this->assertPositiveAmount($amount); if ($idempotencyKey === '') { throw new ValidationException('幂等键不能为空'); } if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { - $this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_REFUND_REVERSE, $bizNo, $amount, LedgerConstant::DIRECTION_OUT); + $this->assertLedgerMatch($existing, $bizType, $bizNo, $amount, LedgerConstant::DIRECTION_OUT); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + if ((int) $account->available_balance < $amount) { + throw new BalanceInsufficientException($merchantId, $amount, (int) $account->available_balance); + } + + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->available_balance = $availableBefore - $amount; + $account->frozen_balance = $frozenBefore + $amount; + $account->save(); + + $this->createFundFreezeRecordIfNeeded($merchantId, $amount, $bizNo, $bizType, $remark, $extJson, $traceNo); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => $bizType, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => LedgerConstant::EVENT_TYPE_CREATE, + 'direction' => LedgerConstant::DIRECTION_OUT, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $remark, + ]); + } + + /** + * 按指定业务类型释放冻结余额。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param int $bizType 流水业务类型 + * @param string $remark 流水备注 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + private function releaseFrozenByBizTypeInCurrentTransaction( + int $merchantId, + int $amount, + string $bizNo, + string $idempotencyKey, + int $bizType, + string $remark, + array $extJson = [], + string $traceNo = '' + ): MerchantAccountLedger { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, $bizType, $bizNo, $amount, LedgerConstant::DIRECTION_IN); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + if ((int) $account->frozen_balance < $amount) { + throw new ValidationException('冻结余额不足', [ + 'merchant_id' => $merchantId, + 'amount' => $amount, + 'frozen_balance' => (int) $account->frozen_balance, + ]); + } + + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->available_balance = $availableBefore + $amount; + $account->frozen_balance = $frozenBefore - $amount; + $account->save(); + + $this->reduceFundFreezeRecordIfNeeded($merchantId, $amount, $bizNo, $bizType, $extJson); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => $bizType, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => LedgerConstant::EVENT_TYPE_REVERSE, + 'direction' => LedgerConstant::DIRECTION_IN, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $remark, + ]); + } + + /** + * 按指定业务类型扣减可用余额。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param int $bizType 流水业务类型 + * @param int $eventType 流水事件类型 + * @param string $remark 流水备注 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + private function debitAvailableByBizTypeInCurrentTransaction( + int $merchantId, + int $amount, + string $bizNo, + string $idempotencyKey, + int $bizType, + int $eventType, + string $remark, + array $extJson = [], + string $traceNo = '' + ): MerchantAccountLedger { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, $bizType, $bizNo, $amount, LedgerConstant::DIRECTION_OUT); return $existing; } @@ -423,10 +692,10 @@ class MerchantAccountCommandService extends BaseService return $this->createLedger([ 'merchant_id' => $merchantId, - 'biz_type' => LedgerConstant::BIZ_TYPE_REFUND_REVERSE, + 'biz_type' => $bizType, 'biz_no' => $bizNo, 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), - 'event_type' => LedgerConstant::EVENT_TYPE_REVERSE, + 'event_type' => $eventType, 'direction' => LedgerConstant::DIRECTION_OUT, 'amount' => $amount, 'available_before' => $availableBefore, @@ -434,11 +703,153 @@ class MerchantAccountCommandService extends BaseService 'frozen_before' => $frozenBefore, 'frozen_after' => (int) $account->frozen_balance, 'idempotency_key' => $idempotencyKey, - 'remark' => $extJson['remark'] ?? '余额冲减', - 'ext_json' => $extJson, + 'remark' => $remark, ]); } + /** + * 按指定业务类型增加可用余额。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param int $bizType 流水业务类型 + * @param int $eventType 流水事件类型 + * @param string $remark 流水备注 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + private function creditAvailableByBizTypeInCurrentTransaction( + int $merchantId, + int $amount, + string $bizNo, + string $idempotencyKey, + int $bizType, + int $eventType, + string $remark, + array $extJson = [], + string $traceNo = '' + ): MerchantAccountLedger { + $this->assertPositiveAmount($amount); + if ($idempotencyKey === '') { + throw new ValidationException('幂等键不能为空'); + } + + if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) { + $this->assertLedgerMatch($existing, $bizType, $bizNo, $amount, LedgerConstant::DIRECTION_IN); + return $existing; + } + + $account = $this->ensureAccountInCurrentTransaction($merchantId); + $availableBefore = (int) $account->available_balance; + $frozenBefore = (int) $account->frozen_balance; + + $account->available_balance = $availableBefore + $amount; + $account->save(); + + return $this->createLedger([ + 'merchant_id' => $merchantId, + 'biz_type' => $bizType, + 'biz_no' => $bizNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'event_type' => $eventType, + 'direction' => LedgerConstant::DIRECTION_IN, + 'amount' => $amount, + 'available_before' => $availableBefore, + 'available_after' => (int) $account->available_balance, + 'frozen_before' => $frozenBefore, + 'frozen_after' => (int) $account->frozen_balance, + 'idempotency_key' => $idempotencyKey, + 'remark' => $remark, + ]); + } + + /** + * 为会进入账户冻结余额的业务创建冻结明细。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param int $bizType 账户流水业务类型 + * @param string $remark 备注 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return void + */ + private function createFundFreezeRecordIfNeeded(int $merchantId, int $amount, string $bizNo, int $bizType, string $remark, array $extJson = [], string $traceNo = ''): void + { + if ($bizType !== LedgerConstant::BIZ_TYPE_PAY_FREEZE) { + return; + } + + $payNo = trim((string) ($extJson['pay_no'] ?? $bizNo)); + if ($payNo === '') { + return; + } + + if ($this->fundFreezeRepository->firstActiveForUpdateByPayNoAndType($payNo, FundFreezeConstant::TYPE_PAY_FEE, $this->now())) { + return; + } + + $this->fundFreezeRepository->create([ + 'freeze_no' => (string) ($extJson['freeze_no'] ?? $this->generateNo('FRZ')), + 'merchant_id' => $merchantId, + 'biz_no' => $bizNo, + 'pay_no' => $payNo, + 'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo), + 'freeze_type' => FundFreezeConstant::TYPE_PAY_FEE, + 'freeze_amount' => $amount, + 'remaining_amount' => $amount, + 'status' => FundFreezeConstant::STATUS_ACTIVE, + 'reason' => $remark, + 'admin_id' => 0, + 'available_at' => null, + 'frozen_at' => $this->now(), + 'release_reason' => '', + 'released_by' => 0, + 'released_at' => null, + ]); + } + + /** + * 账户冻结余额减少时,同步扣减冻结明细剩余金额。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param int $bizType 账户流水业务类型 + * @param array $extJson 扩展字段 + * @return void + */ + private function reduceFundFreezeRecordIfNeeded(int $merchantId, int $amount, string $bizNo, int $bizType, array $extJson = []): void + { + if (!in_array($bizType, [LedgerConstant::BIZ_TYPE_PAY_DEDUCT, LedgerConstant::BIZ_TYPE_PAY_RELEASE], true)) { + return; + } + + $payNo = trim((string) ($extJson['pay_no'] ?? $bizNo)); + if ($payNo === '') { + return; + } + + $freeze = $this->fundFreezeRepository->firstActiveForUpdateByPayNoAndType($payNo, FundFreezeConstant::TYPE_PAY_FEE, $this->now()); + if (!$freeze) { + return; + } + + $remainingAmount = max(0, (int) $freeze->remaining_amount - $amount); + $freeze->remaining_amount = $remainingAmount; + if ($remainingAmount === 0) { + $freeze->status = FundFreezeConstant::STATUS_RELEASED; + $freeze->release_reason = (string) ($extJson['remark'] ?? '支付服务费冻结释放'); + $freeze->released_by = 0; + $freeze->released_at = $this->now(); + } + $freeze->save(); + } + /** * 创建账户流水。 * @@ -447,6 +858,7 @@ class MerchantAccountCommandService extends BaseService */ private function createLedger(array $data): MerchantAccountLedger { + unset($data['ext_json']); $data['ledger_no'] = $data['ledger_no'] ?? $this->generateNo('LG'); $data['trace_no'] = trim((string) ($data['trace_no'] ?? $data['biz_no'] ?? '')); $data['created_at'] = $data['created_at'] ?? $this->now(); @@ -520,6 +932,3 @@ class MerchantAccountCommandService extends BaseService } - - - diff --git a/app/service/account/funds/MerchantAccountService.php b/app/service/account/funds/MerchantAccountService.php index f1f9fc2..356f841 100644 --- a/app/service/account/funds/MerchantAccountService.php +++ b/app/service/account/funds/MerchantAccountService.php @@ -104,6 +104,22 @@ class MerchantAccountService extends BaseService return $this->commandService->freezeAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); } + /** + * 在当前事务中冻结风控资金。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function freezeRiskAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->freezeRiskAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + /** * 扣减冻结余额。 * @@ -168,6 +184,22 @@ class MerchantAccountService extends BaseService return $this->commandService->releaseFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); } + /** + * 在当前事务中释放风控冻结资金。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function releaseRiskFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->releaseRiskFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + /** * 增加可用余额。 * @@ -232,6 +264,70 @@ class MerchantAccountService extends BaseService return $this->commandService->debitAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); } + /** + * 在当前事务中扣减自收通道支付平台服务费。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function debitPayFeeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->debitPayFeeAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + /** + * 在当前事务中扣减转账本金。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function debitTransferAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->debitTransferAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + /** + * 在当前事务中扣减转账手续费。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function debitTransferFeeInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->debitTransferFeeInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + + /** + * 在当前事务中释放失败转账已扣金额。 + * + * @param int $merchantId 商户ID + * @param int $amount 金额(分) + * @param string $bizNo 业务单号 + * @param string $idempotencyKey 幂等键 + * @param array $extJson 扩展字段 + * @param string $traceNo 追踪号 + * @return MerchantAccountLedger 流水记录 + */ + public function releaseTransferAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger + { + return $this->commandService->releaseTransferAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo); + } + /** * 获取余额快照。 * @@ -254,5 +350,3 @@ class MerchantAccountService extends BaseService return $this->queryService->findById($id); } } - - diff --git a/app/service/account/ledger/MerchantAccountLedgerService.php b/app/service/account/ledger/MerchantAccountLedgerService.php index 5d44f55..ad3ddc3 100644 --- a/app/service/account/ledger/MerchantAccountLedgerService.php +++ b/app/service/account/ledger/MerchantAccountLedgerService.php @@ -114,7 +114,6 @@ class MerchantAccountLedgerService extends BaseService $row->frozen_before_text = $this->formatAmount((int) $row->frozen_before); $row->frozen_after_text = $this->formatAmount((int) $row->frozen_after); $row->created_at_text = $this->formatDateTime($row->created_at ?? null); - $row->ext_json_text = $this->formatJson($row->ext_json ?? null); return $row; } @@ -146,7 +145,6 @@ class MerchantAccountLedgerService extends BaseService 'l.frozen_after', 'l.idempotency_key', 'l.remark', - 'l.ext_json', 'l.created_at', ]) ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") @@ -158,4 +156,3 @@ class MerchantAccountLedgerService extends BaseService } - diff --git a/app/service/merchant/MerchantOverviewQueryService.php b/app/service/merchant/MerchantOverviewQueryService.php index cb49b15..04d2106 100644 --- a/app/service/merchant/MerchantOverviewQueryService.php +++ b/app/service/merchant/MerchantOverviewQueryService.php @@ -125,7 +125,6 @@ class MerchantOverviewQueryService extends BaseService 'has_credential' => $credential !== null, 'credential_enabled' => (int) ($credential->status ?? 0) === CommonConstant::STATUS_ENABLED, 'credential_status_text' => (int) ($credential->status ?? 0) === CommonConstant::STATUS_ENABLED ? '已开通' : '未开通', - 'sign_type_text' => $this->textFromMap((int) ($credential->sign_type ?? 0), \app\common\constant\AuthConstant::signTypeMap()), 'credential_last_used_at' => $this->formatDateTime($credential->last_used_at ?? null), ], 'route' => [ @@ -153,4 +152,3 @@ class MerchantOverviewQueryService extends BaseService } - diff --git a/app/service/merchant/auth/MerchantAuthService.php b/app/service/merchant/auth/MerchantAuthService.php index d7f04dd..3f9c632 100644 --- a/app/service/merchant/auth/MerchantAuthService.php +++ b/app/service/merchant/auth/MerchantAuthService.php @@ -136,7 +136,7 @@ class MerchantAuthService extends BaseService * @param string $password 密码 * @param string $ip 请求 IP * @param string $userAgent 用户代理 - * @return array{token: string, expires_in: int, merchant: Merchant, credential: array{status: int, sign_type: int, last_used_at: mixed}|null} 登录结果 + * @return array{token: string, expires_in: int, merchant: Merchant, credential: array{status: int, last_used_at: mixed}|null} 登录结果 * @throws ValidationException */ public function authenticateCredentials(string $merchantNo, string $password, string $ip = '', string $userAgent = ''): array @@ -183,7 +183,7 @@ class MerchantAuthService extends BaseService * @param int $ttlSeconds 过期秒数 * @param string $ip 请求 IP * @param string $userAgent 用户代理 - * @return array{token: string, expires_in: int, merchant: Merchant, credential: array{status: int, sign_type: int, last_used_at: mixed}|null} 登录结果 + * @return array{token: string, expires_in: int, merchant: Merchant, credential: array{status: int, last_used_at: mixed}|null} 登录结果 * @throws ValidationException */ public function issueToken(int $merchantId, int $ttlSeconds = 86400, string $ip = '', string $userAgent = ''): array @@ -213,7 +213,6 @@ class MerchantAuthService extends BaseService 'merchant' => $merchant, 'credential' => $credential ? [ 'status' => (int) ($credential->status ?? 0), - 'sign_type' => (int) ($credential->sign_type ?? 0), 'last_used_at' => $credential->last_used_at, ] : null, ]; diff --git a/app/service/merchant/portal/MerchantPortalSupportService.php b/app/service/merchant/portal/MerchantPortalSupportService.php index 3029d58..b3b4402 100644 --- a/app/service/merchant/portal/MerchantPortalSupportService.php +++ b/app/service/merchant/portal/MerchantPortalSupportService.php @@ -3,7 +3,6 @@ namespace app\service\merchant\portal; use app\common\base\BaseService; -use app\common\constant\AuthConstant; use app\exception\ResourceNotFoundException; use app\repository\merchant\base\MerchantRepository; use app\service\merchant\MerchantService; @@ -171,14 +170,4 @@ class MerchantPortalSupportService extends BaseService return $payTypeId > 0 ? '未知' : ''; } - /** - * 签名类型文案。 - * - * @param int $signType 签名类型 - * @return string 签名类型文本 - */ - public function signTypeText(int $signType): string - { - return $this->textFromMap($signType, AuthConstant::signTypeMap()); - } } diff --git a/app/service/merchant/security/MerchantApiCredentialQueryService.php b/app/service/merchant/security/MerchantApiCredentialQueryService.php index 3ef45d3..4c98c76 100644 --- a/app/service/merchant/security/MerchantApiCredentialQueryService.php +++ b/app/service/merchant/security/MerchantApiCredentialQueryService.php @@ -62,13 +62,11 @@ class MerchantApiCredentialQueryService extends BaseService ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); $paginator->getCollection()->transform(function ($row) { - $row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap()); $row->status_text = $this->textFromMap((int) $row->status, AuthConstant::credentialStatusMap()); $row->platform_public_key_preview = $this->maskCredentialValue( trim((string) config('epay.v2.platform_public_key', '')), false ); - $row->platform_sign_type_text = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA); return $row; }); @@ -114,7 +112,6 @@ class MerchantApiCredentialQueryService extends BaseService ->select([ 'c.id', 'c.merchant_id', - 'c.sign_type', 'c.merchant_public_key', 'c.status', 'c.last_used_at', @@ -151,11 +148,9 @@ class MerchantApiCredentialQueryService extends BaseService $row->api_key_preview = $this->maskCredentialValue((string) ($row->api_key ?? ''), false); $row->merchant_public_key_preview = $this->maskCredentialValue((string) ($row->merchant_public_key ?? ''), false); - $row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap()); $row->status_text = $this->textFromMap((int) $row->status, AuthConstant::credentialStatusMap()); $row->platform_public_key_full = trim((string) config('epay.v2.platform_public_key', '')); $row->platform_public_key_preview = $this->maskCredentialValue((string) $row->platform_public_key_full, false); - $row->platform_sign_type_text = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA); return $row; } diff --git a/app/service/merchant/security/MerchantApiCredentialService.php b/app/service/merchant/security/MerchantApiCredentialService.php index c2e6ea4..073e3a8 100644 --- a/app/service/merchant/security/MerchantApiCredentialService.php +++ b/app/service/merchant/security/MerchantApiCredentialService.php @@ -96,7 +96,6 @@ class MerchantApiCredentialService extends BaseService throw new ValidationException('请至少选择一种要生成的凭证类型'); } - $signType = (int) ($options['sign_type'] ?? ($current?->sign_type ?? AuthConstant::API_SIGN_TYPE_MD5)); $status = (int) ($options['status'] ?? ($current?->status ?? AuthConstant::CREDENTIAL_STATUS_ENABLED)); $credentialValue = $rotateV1 ? $this->generateCredentialValue() : trim((string) ($current?->api_key ?? '')); $merchantPrivateKey = ''; @@ -112,7 +111,6 @@ class MerchantApiCredentialService extends BaseService ['merchant_id' => $merchantId], [ 'merchant_id' => $merchantId, - 'sign_type' => $signType, 'status' => $status, 'api_key' => $credentialValue, 'merchant_public_key' => $merchantPublicKey, @@ -274,19 +272,16 @@ class MerchantApiCredentialService extends BaseService * @param bool $isUpdate 是否更新 * @param MerchantApiCredential|null $current 当前凭证 * 更新场景下,空字符串视为“不修改”,避免手动配置时误清空已有密钥。 - * `sign_type` 在当前阶段只作为展示/默认接入说明,不再作为 V1/V2 互斥开关。 * - * @return array{merchant_id: int, sign_type: int, status: int, api_key?: string} 标准化后的写入数据 + * @return array{merchant_id: int, status: int, api_key?: string} 标准化后的写入数据 */ private function normalizePayload(array $data, bool $isUpdate, ?MerchantApiCredential $current = null): array { // 更新场景下以现有记录的 merchant_id 为准,避免把凭证误挂到别的商户。 $merchantId = (int) ($current?->merchant_id ?? ($data['merchant_id'] ?? 0)); - $currentSignType = (int) ($current?->sign_type ?? AuthConstant::API_SIGN_TYPE_MD5); $currentStatus = (int) ($current?->status ?? AuthConstant::CREDENTIAL_STATUS_ENABLED); $payload = [ 'merchant_id' => $merchantId, - 'sign_type' => (int) ($data['sign_type'] ?? $currentSignType), 'status' => (int) ($data['status'] ?? $currentStatus), ]; diff --git a/app/service/ops/stat/ChannelDailyStatService.php b/app/service/ops/stat/ChannelDailyStatService.php index cc1c337..661bf03 100644 --- a/app/service/ops/stat/ChannelDailyStatService.php +++ b/app/service/ops/stat/ChannelDailyStatService.php @@ -3,6 +3,8 @@ namespace app\service\ops\stat; use app\common\base\BaseService; +use app\model\payment\PayOrder; +use app\model\payment\RefundOrder; use app\model\admin\ChannelDailyStat; use app\repository\ops\stat\ChannelDailyStatRepository; @@ -93,6 +95,68 @@ class ChannelDailyStatService extends BaseService return $row ?: null; } + /** + * 记录支付成功统计。 + * + * @param PayOrder $payOrder 支付单 + * @return void + */ + public function recordPaySuccess(PayOrder $payOrder): void + { + $this->applyDelta($this->buildBaseDelta($payOrder) + [ + 'pay_success_count' => 1, + 'pay_fail_count' => 0, + 'pay_amount' => (int) $payOrder->pay_amount, + 'refund_count' => 0, + 'refund_amount' => 0, + 'latency_ms' => $this->resolvePayLatencyMs($payOrder), + ]); + } + + /** + * 记录支付失败类统计。 + * + * @param PayOrder $payOrder 支付单 + * @return void + */ + public function recordPayFailure(PayOrder $payOrder): void + { + $this->applyDelta($this->buildBaseDelta($payOrder) + [ + 'pay_success_count' => 0, + 'pay_fail_count' => 1, + 'pay_amount' => 0, + 'refund_count' => 0, + 'refund_amount' => 0, + 'latency_ms' => 0, + ]); + } + + /** + * 记录退款成功统计。 + * + * @param RefundOrder $refundOrder 退款单 + * @return void + */ + public function recordRefundSuccess(RefundOrder $refundOrder): void + { + if ((int) $refundOrder->channel_id <= 0) { + return; + } + + $this->applyDelta([ + 'merchant_id' => (int) $refundOrder->merchant_id, + 'merchant_group_id' => (int) $refundOrder->merchant_group_id, + 'channel_id' => (int) $refundOrder->channel_id, + 'stat_date' => $this->resolveDate($refundOrder->succeeded_at ?: $refundOrder->updated_at ?: $refundOrder->created_at), + 'pay_success_count' => 0, + 'pay_fail_count' => 0, + 'pay_amount' => 0, + 'refund_count' => 1, + 'refund_amount' => (int) $refundOrder->refund_amount, + 'latency_ms' => 0, + ]); + } + /** * 格式化单条统计记录。 * @@ -112,6 +176,128 @@ class ChannelDailyStatService extends BaseService return $row; } + /** + * 构建支付单统计基础维度。 + * + * @param PayOrder $payOrder 支付单 + * @return array 统计维度 + */ + private function buildBaseDelta(PayOrder $payOrder): array + { + return [ + 'merchant_id' => (int) $payOrder->merchant_id, + 'merchant_group_id' => (int) $payOrder->merchant_group_id, + 'channel_id' => (int) $payOrder->channel_id, + 'stat_date' => $this->resolveDate($payOrder->paid_at ?: $payOrder->failed_at ?: $payOrder->closed_at ?: $payOrder->timeout_at ?: $payOrder->updated_at ?: $payOrder->created_at), + ]; + } + + /** + * 应用统计增量。 + * + * @param array $delta 统计增量 + * @return void + */ + private function applyDelta(array $delta): void + { + $channelId = (int) ($delta['channel_id'] ?? 0); + if ($channelId <= 0) { + return; + } + + $this->transactionRetry(function () use ($delta, $channelId): void { + $statDate = (string) ($delta['stat_date'] ?? date('Y-m-d')); + $row = $this->channelDailyStatRepository->findForUpdateByChannelAndDate($channelId, $statDate); + if (!$row) { + $row = $this->channelDailyStatRepository->create([ + 'merchant_id' => (int) ($delta['merchant_id'] ?? 0), + 'merchant_group_id' => (int) ($delta['merchant_group_id'] ?? 0), + 'channel_id' => $channelId, + 'stat_date' => $statDate, + 'pay_success_count' => 0, + 'pay_fail_count' => 0, + 'pay_amount' => 0, + 'refund_count' => 0, + 'refund_amount' => 0, + 'avg_latency_ms' => 0, + 'success_rate_bp' => 0, + 'health_score' => 0, + ]); + $row = $this->channelDailyStatRepository->findForUpdateByChannelAndDate($channelId, $statDate) ?: $row; + } + + $previousSuccess = (int) $row->pay_success_count; + $successDelta = (int) ($delta['pay_success_count'] ?? 0); + $latencyMs = (int) ($delta['latency_ms'] ?? 0); + + $row->merchant_id = (int) ($row->merchant_id ?: ($delta['merchant_id'] ?? 0)); + $row->merchant_group_id = (int) ($row->merchant_group_id ?: ($delta['merchant_group_id'] ?? 0)); + $row->pay_success_count = $previousSuccess + $successDelta; + $row->pay_fail_count = (int) $row->pay_fail_count + (int) ($delta['pay_fail_count'] ?? 0); + $row->pay_amount = (int) $row->pay_amount + (int) ($delta['pay_amount'] ?? 0); + $row->refund_count = (int) $row->refund_count + (int) ($delta['refund_count'] ?? 0); + $row->refund_amount = (int) $row->refund_amount + (int) ($delta['refund_amount'] ?? 0); + + if ($successDelta > 0 && $latencyMs > 0) { + $row->avg_latency_ms = $previousSuccess > 0 + ? (int) floor(((int) $row->avg_latency_ms * $previousSuccess + $latencyMs) / max(1, $previousSuccess + $successDelta)) + : $latencyMs; + } + + $this->refreshQualityFields($row); + $row->save(); + }); + } + + /** + * 刷新成功率和健康分。 + * + * 成功率以万分比保存,健康分以成功率百分制为基础,并按平均耗时做轻量扣分。 + * + * @param ChannelDailyStat $row 统计记录 + * @return void + */ + private function refreshQualityFields(ChannelDailyStat $row): void + { + $successCount = (int) $row->pay_success_count; + $failCount = (int) $row->pay_fail_count; + $total = $successCount + $failCount; + + $row->success_rate_bp = $total > 0 ? (int) floor($successCount * 10000 / $total) : 0; + $latencyPenalty = min(30, (int) floor((int) $row->avg_latency_ms / 1000)); + $row->health_score = $total > 0 ? max(0, min(100, (int) floor($row->success_rate_bp / 100) - $latencyPenalty)) : 0; + } + + /** + * 计算支付成功耗时。 + * + * @param PayOrder $payOrder 支付单 + * @return int 耗时毫秒数 + */ + private function resolvePayLatencyMs(PayOrder $payOrder): int + { + $start = strtotime((string) ($payOrder->request_at ?: $payOrder->created_at)); + $end = strtotime((string) ($payOrder->paid_at ?: $payOrder->updated_at)); + if (!$start || !$end || $end < $start) { + return 0; + } + + return (int) (($end - $start) * 1000); + } + + /** + * 解析统计日期。 + * + * @param mixed $value 时间值 + * @return string 日期,格式 Y-m-d + */ + private function resolveDate(mixed $value): string + { + $timestamp = strtotime((string) $value); + + return $timestamp ? date('Y-m-d', $timestamp) : date('Y-m-d'); + } + /** * 构建基础查询。 * @@ -151,6 +337,3 @@ class ChannelDailyStatService extends BaseService } - - - diff --git a/app/service/payment/cashier/CashierService.php b/app/service/payment/cashier/CashierService.php index 5a886e5..15f2b14 100644 --- a/app/service/payment/cashier/CashierService.php +++ b/app/service/payment/cashier/CashierService.php @@ -6,6 +6,7 @@ use app\common\base\BaseService; use app\common\constant\CommonConstant; use app\common\constant\TradeConstant; use app\common\util\FormatHelper; +use app\exception\BusinessStateException; use app\exception\ResourceNotFoundException; use app\exception\ValidationException; use app\model\merchant\Merchant; @@ -16,8 +17,8 @@ use app\repository\payment\trade\PayOrderRepository; use app\service\merchant\MerchantService; use app\service\payment\config\PaymentTypeService; use app\service\payment\order\PayOrderService; -use app\service\payment\order\PaymentOrderInputAssembler; use app\service\payment\runtime\PaymentRouteService; +use app\service\system\config\SystemPublicConfigService; use support\Request; /** @@ -34,7 +35,7 @@ class CashierService extends BaseService protected BizOrderRepository $bizOrderRepository, protected PayOrderRepository $payOrderRepository, protected PayOrderService $payOrderService, - protected PaymentOrderInputAssembler $orderInputAssembler + protected SystemPublicConfigService $systemPublicConfigService ) { } @@ -46,6 +47,8 @@ class CashierService extends BaseService */ public function context(string $bizNo): array { + $this->assertCashierEnabled(); + $bizNo = trim($bizNo); if ($bizNo === '') { throw new ValidationException('biz_no 不能为空'); @@ -85,6 +88,7 @@ class CashierService extends BaseService 'active_pay_order' => $activePayOrder ? $this->formatActivePayOrder($activePayOrder) : null, 'available_pay_types' => $availablePayTypes, 'can_pay' => $canPay, + 'public_config' => $this->systemPublicConfigService->cashier(), ]; } @@ -97,6 +101,8 @@ class CashierService extends BaseService */ public function confirm(array $input, Request $request): array { + $this->assertCashierEnabled(); + $bizNo = trim((string) ($input['biz_no'] ?? '')); $typeCode = trim((string) ($input['type'] ?? '')); if ($bizNo === '') { @@ -128,41 +134,34 @@ class CashierService extends BaseService } // 收银台确认阶段只认业务单快照,避免前端再次篡改订单展示字段。 - $orderFields = $this->orderInputAssembler->buildOrderFields( - [], - null, - $bizOrder, - (array) ($bizOrder->ext_json ?? []) - ); - - // BizOrder 作为收银台上下文的种子,PayOrder 才是真正的支付快照。 $attempt = $this->payOrderService->preparePayAttempt([ 'merchant_id' => (int) $bizOrder->merchant_id, 'merchant_order_no' => (string) $bizOrder->merchant_order_no, 'pay_type_id' => (int) $paymentType->id, 'pay_amount' => (int) $bizOrder->order_amount, - 'subject' => (string) $orderFields['subject'], - 'body' => (string) $orderFields['body'], - 'notify_url' => (string) $orderFields['notify_url'], - 'return_url' => (string) $orderFields['return_url'], - 'client_ip' => (string) $orderFields['client_ip'], - 'device' => (string) $orderFields['device'], - 'ext_json' => (array) $orderFields['ext_json'], + 'subject' => (string) $bizOrder->subject, + 'body' => (string) $bizOrder->body, + 'notify_url' => (string) $bizOrder->notify_url, + 'return_url' => (string) $bizOrder->return_url, + 'client_ip' => (string) $bizOrder->client_ip, + 'device' => (string) $bizOrder->device, + 'ext_json' => (array) ($bizOrder->ext_json ?? []), ]); /** @var PayOrder $payOrder */ $payOrder = $attempt['pay_order']; $payParams = (array) ($attempt['pay_params'] ?? []); $paymentResult = (array) ($attempt['payment_result'] ?? []); + $paymentPagePath = $this->buildPaymentPagePath((string) $payOrder->pay_no); return [ 'biz_no' => (string) $bizOrder->biz_no, 'trade_no' => (string) $payOrder->pay_no, - 'pay_type' => strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? 'qrcode'))), + 'pay_type' => strtolower(trim((string) ($paymentResult['pay_page'] ?? 'qrcode'))), 'pay_info' => $payParams, 'payment_result' => $paymentResult, - 'payment_page_path' => $this->buildPaymentPagePath((string) $payOrder->pay_no), - 'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no), + 'payment_page_path' => $paymentPagePath, + 'payment_page_url' => $this->buildSiteUrl($paymentPagePath), ]; } @@ -174,7 +173,126 @@ class CashierService extends BaseService */ public function payOrderDetail(string $payNo): array { - return $this->payOrderService->detail($payNo); + $this->assertCashierEnabled(); + + $payNo = trim($payNo); + if ($payNo === '') { + throw new ValidationException('pay_no 不能为空'); + } + + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + if (!$bizOrder) { + throw new ResourceNotFoundException('业务单不存在', ['biz_no' => (string) $payOrder->biz_no]); + } + + $merchant = $this->merchantService->ensureMerchantEnabled((int) $payOrder->merchant_id); + $paymentType = $this->paymentTypeService->findById((int) $payOrder->pay_type_id); + $presentation = $this->resolvePresentation($payOrder); + + return [ + 'order' => [ + 'pay_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'subject' => (string) $bizOrder->subject, + 'amount' => (int) $payOrder->pay_amount, + 'currency' => 'CNY', + 'status' => (int) $payOrder->status, + 'status_text' => (string) (TradeConstant::orderStatusMap()[(int) $payOrder->status] ?? ''), + 'created_at' => FormatHelper::dateTime($payOrder->request_at ?: $payOrder->created_at), + 'expire_at' => FormatHelper::dateTime($payOrder->expire_at), + 'updated_at' => FormatHelper::dateTime($payOrder->updated_at), + 'return_url' => (string) ($payOrder->return_url ?: $bizOrder->return_url), + ], + 'merchant' => [ + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) ($merchant->merchant_no ?? ''), + 'merchant_name' => (string) ($merchant->merchant_name ?? ''), + 'merchant_short_name' => (string) ($merchant->merchant_short_name ?? ''), + ], + 'payment_type' => [ + 'id' => (int) $payOrder->pay_type_id, + 'code' => (string) ($paymentType->code ?? ''), + 'name' => (string) ($paymentType->name ?? ''), + 'icon' => (string) ($paymentType->icon ?? ''), + ], + 'presentation' => $presentation, + 'cashier_path' => $this->buildCashierPath((string) $payOrder->biz_no), + 'payment_path' => $this->buildPaymentPagePath((string) $payOrder->pay_no), + 'public_config' => $this->systemPublicConfigService->cashier(), + ]; + } + + /** + * 确认收银台已开启。 + * + * @return void + */ + private function assertCashierEnabled(): void + { + if (!$this->boolConfig('cashier_enabled', true)) { + throw new BusinessStateException('收银台已关闭'); + } + } + + /** + * 读取布尔配置。 + * + * @param string $key 配置键 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function boolConfig(string $key, bool $default): bool + { + $value = strtolower(trim((string) sys_config($key, $default ? '1' : '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } + + /** + * 查询支付单状态。 + * + * 状态轮询只查支付单表,避免反复构建支付详情 DTO 带来的多表查询开销。 + * + * @param string $payNo 支付单号 + * @return array + */ + public function payOrderStatus(string $payNo): array + { + $this->assertCashierEnabled(); + + $payNo = trim($payNo); + if ($payNo === '') { + throw new ValidationException('pay_no 不能为空'); + } + + $payOrder = $this->payOrderRepository->findByPayNo($payNo, [ + 'pay_no', + 'status', + 'paid_at', + 'closed_at', + 'failed_at', + 'timeout_at', + 'updated_at', + ]); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + return [ + 'pay_no' => (string) $payOrder->pay_no, + 'status' => (int) $payOrder->status, + 'status_text' => (string) (TradeConstant::orderStatusMap()[(int) $payOrder->status] ?? ''), + 'paid_at' => FormatHelper::dateTime($payOrder->paid_at), + 'closed_at' => FormatHelper::dateTime($payOrder->closed_at), + 'failed_at' => FormatHelper::dateTime($payOrder->failed_at), + 'timeout_at' => FormatHelper::dateTime($payOrder->timeout_at), + 'updated_at' => FormatHelper::dateTime($payOrder->updated_at), + ]; } /** @@ -255,16 +373,21 @@ class CashierService extends BaseService */ private function formatActivePayOrder(PayOrder $payOrder): array { + $paymentPagePath = $this->buildPaymentPagePath((string) $payOrder->pay_no); + $paymentType = $this->paymentTypeService->findById((int) $payOrder->pay_type_id); + return [ 'pay_no' => (string) $payOrder->pay_no, - 'pay_type_code' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id), + 'pay_type_code' => (string) ($paymentType->code ?? ''), + 'pay_type_name' => (string) ($paymentType->name ?? ''), + 'pay_type_icon' => (string) ($paymentType->icon ?? ''), 'pay_amount' => (int) $payOrder->pay_amount, 'pay_amount_text' => FormatHelper::amount((int) $payOrder->pay_amount), 'status' => (int) $payOrder->status, 'created_at' => FormatHelper::dateTime($payOrder->created_at), 'request_at' => FormatHelper::dateTime($payOrder->request_at), - 'payment_page_path' => $this->buildPaymentPagePath((string) $payOrder->pay_no), - 'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no), + 'payment_page_path' => $paymentPagePath, + 'payment_page_url' => $this->buildSiteUrl($paymentPagePath), ]; } @@ -280,14 +403,47 @@ class CashierService extends BaseService } /** - * 构建支付页完整地址。 + * 构建业务单入口路径。 * - * @param string $payNo 支付单号 + * @param string $bizNo 业务单号 * @return string */ - private function buildPaymentPageUrl(string $payNo): string + private function buildCashierPath(string $bizNo): string { - return rtrim((string) sys_config('site_url'), '/') . $this->buildPaymentPagePath($payNo); + return '/cashier/' . rawurlencode($bizNo); + } + + /** + * 构建站点完整地址。 + * + * @param string $path 站内路径 + * @return string + */ + private function buildSiteUrl(string $path): string + { + return rtrim((string) sys_config('site_url'), '/') . $path; + } + + /** + * 提取收银台支付承接快照。 + * + * @param PayOrder $payOrder 支付单 + * @return array + */ + private function resolvePresentation(PayOrder $payOrder): array + { + $extJson = (array) ($payOrder->ext_json ?? []); + $presentation = (array) ($extJson['presentation'] ?? []); + $payParams = (array) ($presentation['pay_params'] ?? []); + $payParams['server_time_timestamp'] = time(); + + return [ + 'pay_page' => (string) ($presentation['pay_page'] ?? ''), + 'pay_type' => (string) ($presentation['pay_type'] ?? ''), + 'pay_product' => (string) ($presentation['pay_product'] ?? ''), + 'pay_action' => (string) ($presentation['pay_action'] ?? ''), + 'pay_params' => $payParams, + ]; } } diff --git a/app/service/payment/config/PaymentChannelCommandService.php b/app/service/payment/config/PaymentChannelCommandService.php index 5bc968d..2bd3c58 100644 --- a/app/service/payment/config/PaymentChannelCommandService.php +++ b/app/service/payment/config/PaymentChannelCommandService.php @@ -4,12 +4,14 @@ namespace app\service\payment\config; use app\common\base\BaseService; use app\common\constant\CommonConstant; +use app\common\constant\EventConstant; use app\exception\PaymentException; use app\model\payment\PaymentChannel; use app\repository\merchant\base\MerchantRepository; use app\repository\payment\config\PaymentChannelRepository; use app\repository\payment\config\PaymentPluginRepository; use app\repository\payment\config\PaymentTypeRepository; +use Webman\Event\Event; /** * 支付通道命令服务。 @@ -65,7 +67,10 @@ class PaymentChannelCommandService extends BaseService $this->assertMerchantExists($data); $this->assertPluginSupportsPayType($data); - return $this->paymentChannelRepository->create($data); + $channel = $this->paymentChannelRepository->create($data); + $this->dispatchWatcherConfigChanged('create', $channel); + + return $channel; } /** @@ -87,7 +92,12 @@ class PaymentChannelCommandService extends BaseService return null; } - return $this->paymentChannelRepository->find($id); + $channel = $this->paymentChannelRepository->find($id); + if ($channel) { + $this->dispatchWatcherConfigChanged('update', $channel); + } + + return $channel; } /** @@ -98,7 +108,13 @@ class PaymentChannelCommandService extends BaseService */ public function delete(int $id): bool { - return $this->paymentChannelRepository->deleteById($id); + $channel = $this->paymentChannelRepository->find($id); + $deleted = $this->paymentChannelRepository->deleteById($id); + if ($deleted && $channel) { + $this->dispatchWatcherConfigChanged('delete', $channel); + } + + return $deleted; } /** @@ -186,6 +202,23 @@ class PaymentChannelCommandService extends BaseService ]); } } + + /** + * 发送网页流水监听配置刷新事件。 + * + * @param string $action 操作 + * @param PaymentChannel $channel 支付通道 + * @return void + */ + private function dispatchWatcherConfigChanged(string $action, PaymentChannel $channel): void + { + Event::dispatch(EventConstant::PAYMENT_RECEIPT_WATCHER_CONFIG_CHANGED, [ + 'source' => 'payment_channel', + 'action' => $action, + 'channel_id' => (int) $channel->id, + 'plugin_code' => (string) $channel->plugin_code, + 'api_config_id' => (int) $channel->api_config_id, + ]); + } } - diff --git a/app/service/payment/config/PaymentChannelTestService.php b/app/service/payment/config/PaymentChannelTestService.php new file mode 100644 index 0000000..af5b02c --- /dev/null +++ b/app/service/payment/config/PaymentChannelTestService.php @@ -0,0 +1,267 @@ + $data 测试入参 + * @return array 测试订单与支付页信息 + */ + public function submit(int $channelId, array $data): array + { + if (!$this->boolConfig('channel_test_enabled', true)) { + throw new ValidationException('通道测试已关闭'); + } + + $merchantId = (int) sys_config('channel_test_merchant_id', 0, true); + if ($merchantId <= 0) { + throw new ValidationException('请先在系统配置中填写测试商户ID'); + } + + /** @var PaymentChannel|null $channel */ + $channel = $this->paymentChannelRepository->find($channelId); + if (!$channel) { + throw new ValidationException('支付通道不存在', ['channel_id' => $channelId]); + } + + $money = trim((string) ($data['money'] ?? '')); + $payAmount = $this->parseMoneyToAmount($money); + if ($payAmount <= 0) { + throw new ValidationException('测试金额不合法'); + } + + $subject = trim((string) ($data['name'] ?? '')); + if ($subject === '') { + throw new ValidationException('商品名称不能为空'); + } + + $this->assertChannelAmountAllowed($channel, $payAmount); + + $merchantOrderNo = $this->generateNo('CHTEST' . $channelId); + + try { + $attempt = $this->payOrderService->preparePayAttemptByChannel([ + 'merchant_id' => $merchantId, + 'merchant_order_no' => $merchantOrderNo, + 'pay_type_id' => (int) $channel->pay_type_id, + 'pay_amount' => $payAmount, + 'subject' => $subject, + 'body' => $subject, + 'notify_url' => $this->buildDefaultNotifyUrl(), + 'return_url' => $this->buildDefaultReturnUrl(), + 'client_ip' => trim((string) ($data['client_ip'] ?? '')), + 'device' => 'pc', + 'ext_json' => [ + '_submit_type' => EpayProtocolConstant::SUBMIT_TYPE_PAGE, + ], + ], $channel); + } catch (PaymentException $e) { + $payOrder = $this->findPayOrderByMerchantOrderNo($merchantId, $merchantOrderNo); + if ($payOrder) { + return $this->formatTestResult($payOrder, $e->getMessage()); + } + + throw $e; + } + + /** @var PayOrder $payOrder */ + $payOrder = $attempt['pay_order']; + $result = $this->formatTestResult($payOrder); + $this->writeDebugLog($result); + + return $result; + } + + /** + * 根据测试商户订单号查询支付单。 + * + * @param int $merchantId 商户ID + * @param string $merchantOrderNo 商户订单号 + * @return PayOrder|null 支付单 + */ + private function findPayOrderByMerchantOrderNo(int $merchantId, string $merchantOrderNo): ?PayOrder + { + $bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo); + if (!$bizOrder) { + return null; + } + + return $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no); + } + + /** + * 格式化测试结果。 + * + * @param PayOrder $payOrder 支付单 + * @param string $errorMessage 错误消息,非空时返回给后台提示,承接页从订单快照读取错误 + * @return array 测试订单与支付页信息 + */ + private function formatTestResult(PayOrder $payOrder, string $errorMessage = ''): array + { + $payNo = (string) $payOrder->pay_no; + $pagePath = $this->buildPaymentPagePath($payNo); + + return [ + 'pay_no' => $payNo, + 'biz_no' => (string) $payOrder->biz_no, + 'merchant_id' => (int) $payOrder->merchant_id, + 'channel_id' => (int) $payOrder->channel_id, + 'channel_order_no' => (string) ($payOrder->channel_order_no ?? ''), + 'money' => FormatHelper::amount((int) $payOrder->pay_amount), + 'payment_page_path' => $pagePath, + 'payment_page_url' => $this->buildSiteUrl($pagePath), + 'error_msg' => $errorMessage, + ]; + } + + /** + * 校验测试金额是否落在通道金额区间内。 + * + * @param PaymentChannel $channel 支付通道 + * @param int $payAmount 支付金额,单位分 + * @return void + */ + private function assertChannelAmountAllowed(PaymentChannel $channel, int $payAmount): void + { + if ((int) $channel->min_amount > 0 && $payAmount < (int) $channel->min_amount) { + throw new ValidationException('测试金额低于通道最小金额', [ + 'min_amount' => FormatHelper::amount((int) $channel->min_amount), + ]); + } + + if ((int) $channel->max_amount > 0 && $payAmount > (int) $channel->max_amount) { + throw new ValidationException('测试金额高于通道最大金额', [ + 'max_amount' => FormatHelper::amount((int) $channel->max_amount), + ]); + } + } + + /** + * 将元金额转成分。 + * + * @param string $money 金额字符串 + * @return int 金额分值,非法时返回 0 + */ + private function parseMoneyToAmount(string $money): int + { + $money = trim($money); + if ($money === '' || !preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + return 0; + } + + [$integer, $fraction] = array_pad(explode('.', $money, 2), 2, ''); + $fraction = str_pad($fraction, 2, '0'); + + return ((int) $integer) * 100 + (int) substr($fraction, 0, 2); + } + + /** + * 构建支付页相对路径。 + * + * @param string $payNo 支付单号 + * @return string 支付页路径 + */ + private function buildPaymentPagePath(string $payNo): string + { + return '/payment/' . rawurlencode($payNo); + } + + /** + * 构建站点完整地址。 + * + * @param string $path 站内路径 + * @return string URL + */ + private function buildSiteUrl(string $path): string + { + $siteUrl = rtrim((string) sys_config('site_url'), '/'); + + return $siteUrl !== '' ? $siteUrl . $path : $path; + } + + /** + * 构建默认同步跳转地址。 + * + * @return string 默认同步跳转地址 + */ + private function buildDefaultReturnUrl(): string + { + $configuredUrl = trim((string) sys_config('channel_test_return_url', '')); + if ($configuredUrl !== '') { + return $configuredUrl; + } + + $siteUrl = rtrim((string) sys_config('site_url'), '/'); + + return $siteUrl !== '' ? $siteUrl . '/payment' : ''; + } + + /** + * 构建测试异步通知地址。 + * + * @return string 测试通知地址 + */ + private function buildDefaultNotifyUrl(): string + { + return trim((string) sys_config('channel_test_notify_url', '')); + } + + /** + * 按配置记录测试调试日志。 + * + * @param array $result 测试结果 + * @return void + */ + private function writeDebugLog(array $result): void + { + if (!$this->boolConfig('channel_test_debug_log_enabled', false)) { + return; + } + + Log::info('[PaymentChannelTest] 通道测试支付已创建 ' . json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + /** + * 读取布尔配置。 + * + * @param string $key 配置键 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function boolConfig(string $key, bool $default): bool + { + $value = strtolower(trim((string) sys_config($key, $default ? '1' : '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } +} diff --git a/app/service/payment/config/PaymentPluginConfService.php b/app/service/payment/config/PaymentPluginConfService.php index ad70e0d..49081cd 100644 --- a/app/service/payment/config/PaymentPluginConfService.php +++ b/app/service/payment/config/PaymentPluginConfService.php @@ -3,10 +3,12 @@ namespace app\service\payment\config; use app\common\base\BaseService; +use app\common\constant\EventConstant; use app\exception\PaymentException; use app\model\payment\PaymentPluginConf; use app\repository\payment\config\PaymentPluginConfRepository; use app\repository\payment\config\PaymentPluginRepository; +use Webman\Event\Event; /** * 支付插件配置服务。 @@ -105,7 +107,10 @@ class PaymentPluginConfService extends BaseService $payload = $this->normalizePayload($data); $this->assertPluginExists((string) $payload['plugin_code']); - return $this->paymentPluginConfRepository->create($payload); + $config = $this->paymentPluginConfRepository->create($payload); + $this->dispatchWatcherConfigChanged('create', $config); + + return $config; } /** @@ -125,7 +130,12 @@ class PaymentPluginConfService extends BaseService return null; } - return $this->paymentPluginConfRepository->find($id); + $config = $this->paymentPluginConfRepository->find($id); + if ($config) { + $this->dispatchWatcherConfigChanged('update', $config); + } + + return $config; } /** @@ -136,7 +146,13 @@ class PaymentPluginConfService extends BaseService */ public function delete(int $id): bool { - return $this->paymentPluginConfRepository->deleteById($id); + $config = $this->paymentPluginConfRepository->find($id); + $deleted = $this->paymentPluginConfRepository->deleteById($id); + if ($deleted && $config) { + $this->dispatchWatcherConfigChanged('delete', $config); + } + + return $deleted; } /** @@ -285,4 +301,21 @@ class PaymentPluginConfService extends BaseService ]); } } + + /** + * 发送网页流水监听配置刷新事件。 + * + * @param string $action 操作 + * @param PaymentPluginConf $config 插件配置 + * @return void + */ + private function dispatchWatcherConfigChanged(string $action, PaymentPluginConf $config): void + { + Event::dispatch(EventConstant::PAYMENT_RECEIPT_WATCHER_CONFIG_CHANGED, [ + 'source' => 'payment_plugin_conf', + 'action' => $action, + 'api_config_id' => (int) $config->id, + 'plugin_code' => (string) $config->plugin_code, + ]); + } } diff --git a/app/service/payment/epay/EpaySignerManager.php b/app/service/payment/epay/EpaySignerManager.php index e861c3f..7f19011 100644 --- a/app/service/payment/epay/EpaySignerManager.php +++ b/app/service/payment/epay/EpaySignerManager.php @@ -30,9 +30,9 @@ class EpaySignerManager */ public function sign(array $params, string $signType, string $key): string { - return match ($this->normalizeSignType($signType)) { + return match (strtoupper(trim($signType))) { AuthConstant::API_SIGN_NAME_MD5 => $this->md5Signer->sign($params, $key), - AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => $this->rsaSigner->sign($params, $key), + AuthConstant::API_SIGN_NAME_RSA => $this->rsaSigner->sign($params, $key), default => throw new PaymentException('不支持的签名类型', 40200), }; } @@ -48,28 +48,10 @@ class EpaySignerManager */ public function verify(array $params, string $signType, string $sign, string $key): bool { - return match ($this->normalizeSignType($signType)) { + return match (strtoupper(trim($signType))) { AuthConstant::API_SIGN_NAME_MD5 => $this->md5Signer->verify($params, $sign, $key), - AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => $this->rsaSigner->verify($params, $sign, $key), + AuthConstant::API_SIGN_NAME_RSA => $this->rsaSigner->verify($params, $sign, $key), default => false, }; } - - /** - * 归一化签名类型。 - * - * @param string $signType 原始签名类型 - * @return string 归一化后的签名类型 - */ - public function normalizeSignType(string $signType): string - { - $signType = strtoupper(trim($signType)); - - return match ($signType) { - 'RSA' => AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA, - AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA, - AuthConstant::API_SIGN_NAME_MD5 => AuthConstant::API_SIGN_NAME_MD5, - default => $signType, - }; - } } diff --git a/app/service/payment/epay/EpaySubmitPayloadAssembler.php b/app/service/payment/epay/EpaySubmitPayloadAssembler.php new file mode 100644 index 0000000..32ff70c --- /dev/null +++ b/app/service/payment/epay/EpaySubmitPayloadAssembler.php @@ -0,0 +1,102 @@ + $seedExtJson 协议入口写入的扩展字段 + * @return array + */ + public function buildOrderFields(array $payload, Request $request, array $seedExtJson = []): array + { + $subject = trim((string) $payload['name']); + + $clientIp = trim((string) ($payload['clientip'] ?? '')); + if ($clientIp === '') { + $clientIp = trim((string) $request->getRealIp()); + } + + $device = trim((string) ($payload['device'] ?? '')); + if ($device === '') { + $device = EpayProtocolConstant::DEVICE_PC; + } + + $extJson = $seedExtJson; + $merchant = array_filter([ + 'param' => $payload['param'] ?? null, + 'buyer' => $payload['buyer'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''); + + if ($merchant !== []) { + $extJson['merchant'] = $merchant; + } + + $payment = array_filter([ + 'method' => $payload['method'] ?? null, + 'auth_code' => $payload['auth_code'] ?? null, + 'sub_openid' => $payload['sub_openid'] ?? null, + 'sub_appid' => $payload['sub_appid'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''); + + if ($payment !== []) { + $extJson['payment'] = $payment; + } + + return [ + 'subject' => $subject, + 'body' => $subject, + 'notify_url' => trim((string) $payload['notify_url']), + 'return_url' => trim((string) ($payload['return_url'] ?? '')), + 'client_ip' => $clientIp, + 'device' => $device, + 'ext_json' => $extJson, + ]; + } + + /** + * 解析页面提交设备类型。 + * + * 页面跳转提交通常不带 device,需要根据请求 UA 推断为协议支持的设备类型。 + * + * @param array $payload 协议请求参数 + * @param Request $request 请求对象 + * @param array $allowedDevices 协议支持设备列表 + * @return string + */ + public function resolvePageSubmitDevice(array $payload, Request $request, array $allowedDevices): string + { + $device = strtolower(trim((string) ($payload['device'] ?? ''))); + if ($device !== '' && in_array($device, $allowedDevices, true)) { + return $device; + } + + $userAgent = strtolower(trim((string) $request->header('user-agent', ''))); + $device = EpayProtocolConstant::DEVICE_PC; + + if (str_contains($userAgent, 'alipayclient')) { + $device = EpayProtocolConstant::DEVICE_ALIPAY; + } elseif (str_contains($userAgent, 'micromessenger')) { + $device = EpayProtocolConstant::DEVICE_WECHAT; + } elseif (str_contains($userAgent, ' qq/') || str_contains($userAgent, 'mqqbrowser')) { + $device = EpayProtocolConstant::DEVICE_QQ; + } elseif (preg_match('/mobile|android|iphone|ipad|ipod|windows phone/i', $userAgent) === 1) { + $device = EpayProtocolConstant::DEVICE_MOBILE; + } + + return in_array($device, $allowedDevices, true) ? $device : EpayProtocolConstant::DEVICE_PC; + } +} diff --git a/app/service/payment/epay/EpayV1ProtocolService.php b/app/service/payment/epay/EpayV1ProtocolService.php index 80f0db6..24e1d5c 100644 --- a/app/service/payment/epay/EpayV1ProtocolService.php +++ b/app/service/payment/epay/EpayV1ProtocolService.php @@ -5,6 +5,7 @@ namespace app\service\payment\epay; use app\common\base\BaseService; use app\common\constant\AuthConstant; use app\common\constant\CommonConstant; +use app\common\constant\EpayProtocolConstant; use app\common\constant\TradeConstant; use app\common\util\FormatHelper; use app\exception\ValidationException; @@ -19,9 +20,8 @@ use app\service\merchant\MerchantService; use app\service\merchant\security\MerchantApiCredentialService; use app\service\payment\config\PaymentTypeService; use app\service\payment\order\PayOrderService; -use app\service\payment\order\PaymentOrderInputAssembler; +use app\service\payment\order\RefundDispatchService; use app\service\payment\order\RefundService; -use app\service\payment\runtime\PaymentPluginManager; use support\Request; use support\Response; use Throwable; @@ -34,8 +34,7 @@ use Throwable; * @property MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务 * @property PaymentTypeService $paymentTypeService 支付类型服务 * @property PayOrderService $payOrderService 支付订单服务 - * @property PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器 - * @property PaymentPluginManager $paymentPluginManager 支付插件管理器 + * @property EpaySubmitPayloadAssembler $submitPayloadAssembler 提交入参组装器 * @property MerchantAccountRepository $merchantAccountRepository 商户账户仓库 * @property BizOrderRepository $bizOrderRepository 业务订单仓库 * @property PayOrderRepository $payOrderRepository 支付单仓库 @@ -44,7 +43,8 @@ use Throwable; */ class EpayV1ProtocolService extends BaseService { - private const API_ACTIONS = ['query', 'settle', 'order', 'orders', 'refund']; + private const SUCCESS_CODE = 1; + private const FAILURE_CODE = 0; /** * 构造方法。 @@ -52,12 +52,12 @@ class EpayV1ProtocolService extends BaseService * @param MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务 * @param PaymentTypeService $paymentTypeService 支付类型服务 * @param PayOrderService $payOrderService 支付订单服务 - * @param PaymentPluginManager $paymentPluginManager 支付插件管理器 * @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库 * @param BizOrderRepository $bizOrderRepository 业务订单仓库 * @param PayOrderRepository $payOrderRepository 支付订单仓库 * @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库 * @param RefundService $refundService 退款服务 + * @param RefundDispatchService $refundDispatchService 退款派发服务 * @return void */ public function __construct( @@ -65,14 +65,14 @@ class EpayV1ProtocolService extends BaseService protected MerchantService $merchantService, protected PaymentTypeService $paymentTypeService, protected PayOrderService $payOrderService, - protected PaymentOrderInputAssembler $orderInputAssembler, - protected PaymentPluginManager $paymentPluginManager, + protected EpaySubmitPayloadAssembler $submitPayloadAssembler, protected EpaySignerManager $epaySignerManager, protected MerchantAccountRepository $merchantAccountRepository, protected BizOrderRepository $bizOrderRepository, protected PayOrderRepository $payOrderRepository, protected SettlementOrderRepository $settlementOrderRepository, - protected RefundService $refundService + protected RefundService $refundService, + protected RefundDispatchService $refundDispatchService ) { } @@ -90,8 +90,7 @@ class EpayV1ProtocolService extends BaseService $typeCode = trim((string) ($payload['type'] ?? '')); if ($typeCode === '') { // `type` 为空时先创建收银台业务单,选完方式后再进入正式支付单流程。 - $attempt = $this->prepareCashierSubmit($payload, $request); - $targetUrl = (string) ($attempt['cashier_url'] ?? ''); + $targetUrl = $this->prepareCashierSubmit($payload, $request); if ($targetUrl === '') { throw new ValidationException('收银台跳转地址生成失败'); } @@ -99,11 +98,23 @@ class EpayV1ProtocolService extends BaseService return redirect($targetUrl); } - return $this->buildBrowserSubmitResponse($this->prepareSubmitAttempt($payload, $request)); + $attempt = $this->prepareSubmitAttempt( + $payload, + $request, + EpayProtocolConstant::SUBMIT_TYPE_PAGE + ); + + return redirect((string) $attempt['payment_page_url']); } catch (Throwable $e) { + $data = method_exists($e, 'getData') ? $e->getData() : []; + $payNo = trim((string) ($data['pay_no'] ?? '')); + if ($payNo !== '') { + return redirect($this->buildPaymentPageUrl($payNo)); + } + return json([ - 'code' => 0, - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } @@ -118,10 +129,10 @@ class EpayV1ProtocolService extends BaseService public function mapi(array $payload, Request $request): array { try { - $attempt = $this->prepareSubmitAttempt($payload, $request); + $attempt = $this->prepareSubmitAttempt($payload, $request, EpayProtocolConstant::SUBMIT_TYPE_API); return $this->buildMapiResponse($attempt); } catch (Throwable $e) { - return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + return ['code' => self::FAILURE_CODE, 'msg' => $e->getMessage() ?: '请求失败']; } } @@ -136,9 +147,6 @@ class EpayV1ProtocolService extends BaseService public function api(array $payload): array { $act = strtolower(trim((string) ($payload['act'] ?? ''))); - if (!in_array($act, self::API_ACTIONS, true)) { - return ['code' => 0, 'msg' => '不支持的操作类型']; - } return match ($act) { 'query' => $this->queryMerchantInfo($payload), @@ -155,7 +163,7 @@ class EpayV1ProtocolService extends BaseService * @param array $payload 请求载荷 * @return array ePay 风格响应 */ - public function queryMerchantInfo(array $payload): array + private function queryMerchantInfo(array $payload): array { try { $merchantId = (int) ($payload['pid'] ?? 0); @@ -172,7 +180,7 @@ class EpayV1ProtocolService extends BaseService $lastDayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $lastDayDate)->count(); return [ - 'code' => 1, + 'code' => self::SUCCESS_CODE, 'pid' => (int) $merchant->id, 'key' => (string) $credential->api_key, 'active' => (int) $merchant->status, @@ -185,7 +193,7 @@ class EpayV1ProtocolService extends BaseService 'order_lastday' => $lastDayOrders, ]; } catch (Throwable $e) { - return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + return ['code' => self::FAILURE_CODE, 'msg' => $e->getMessage() ?: '请求失败']; } } @@ -195,7 +203,7 @@ class EpayV1ProtocolService extends BaseService * @param array $payload 请求载荷 * @return array ePay 风格响应 */ - public function querySettlementList(array $payload): array + private function querySettlementList(array $payload): array { try { $merchantId = (int) ($payload['pid'] ?? 0); @@ -205,7 +213,7 @@ class EpayV1ProtocolService extends BaseService // 旧协议列表只需要基础字段和金额文本,这里直接整理成可展示数组。 return [ - 'code' => 1, + 'code' => self::SUCCESS_CODE, 'msg' => '查询结算记录成功!', 'data' => $rows->map(function ($row): array { return [ @@ -222,7 +230,7 @@ class EpayV1ProtocolService extends BaseService })->all(), ]; } catch (Throwable $e) { - return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + return ['code' => self::FAILURE_CODE, 'msg' => $e->getMessage() ?: '请求失败']; } } @@ -232,7 +240,7 @@ class EpayV1ProtocolService extends BaseService * @param array $payload 请求载荷 * @return array ePay 风格响应 */ - public function queryOrder(array $payload): array + private function queryOrder(array $payload): array { try { $merchantId = (int) ($payload['pid'] ?? 0); @@ -240,13 +248,13 @@ class EpayV1ProtocolService extends BaseService $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); $context = $this->resolvePayOrderContext($merchantId, $payload); if (!$context) { - return ['code' => 0, 'msg' => '订单不存在']; + return ['code' => self::FAILURE_CODE, 'msg' => '订单不存在']; } // 旧协议查询单号时,要把支付单和业务单合并成同一份响应结构。 - return ['code' => 1, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']); + return ['code' => self::SUCCESS_CODE, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']); } catch (Throwable $e) { - return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + return ['code' => self::FAILURE_CODE, 'msg' => $e->getMessage() ?: '请求失败']; } } @@ -256,15 +264,14 @@ class EpayV1ProtocolService extends BaseService * @param array $payload 请求载荷 * @return array ePay 风格响应 */ - public function queryOrders(array $payload): array + private function queryOrders(array $payload): array { try { $merchantId = (int) ($payload['pid'] ?? 0); $key = trim((string) ($payload['key'] ?? '')); $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); - // 旧接口默认只允许一次拉少量订单,这里沿用上限 50 的兼容口径。 - $limit = min(50, max(1, (int) ($payload['limit'] ?? 20))); - $page = max(1, (int) ($payload['page'] ?? 1)); + $limit = (int) ($payload['limit'] ?? 20); + $page = (int) ($payload['page'] ?? 1); $paginator = $this->payOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->paginate($limit, ['*'], 'page', $page); $items = $paginator->items(); $bizOrderMap = []; @@ -279,7 +286,7 @@ class EpayV1ProtocolService extends BaseService } return [ - 'code' => 1, + 'code' => self::SUCCESS_CODE, 'msg' => '查询结算记录成功!', // 批量查询和单条查询共用同一套格式化器,避免字段口径不一致。 'data' => array_map(function ($row) use ($bizOrderMap): array { @@ -289,7 +296,7 @@ class EpayV1ProtocolService extends BaseService }, $items), ]; } catch (Throwable $e) { - return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + return ['code' => self::FAILURE_CODE, 'msg' => $e->getMessage() ?: '请求失败']; } } @@ -299,7 +306,7 @@ class EpayV1ProtocolService extends BaseService * @param array $payload 请求载荷 * @return array ePay 风格响应 */ - public function createRefund(array $payload): array + private function createRefund(array $payload): array { try { $merchantId = (int) ($payload['pid'] ?? 0); @@ -308,82 +315,54 @@ class EpayV1ProtocolService extends BaseService // 先确认退款目标单据归属当前商户,避免旧协议拿着别人的单号误发退款。 $context = $this->resolvePayOrderContext($merchantId, $payload); if (!$context) { - return ['code' => 0, 'msg' => '订单不存在']; + return ['code' => self::FAILURE_CODE, 'msg' => '订单不存在']; } /** @var PayOrder $payOrder */ $payOrder = $context['pay_order']; - $refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0')); - if ($refundAmount <= 0) { - return ['code' => 0, 'msg' => '退款金额不合法']; - } $refundOrder = $this->refundService->createRefund([ 'pay_no' => (string) $payOrder->pay_no, 'merchant_refund_no' => trim((string) ($payload['refund_no'] ?? $payload['merchant_refund_no'] ?? '')), - 'refund_amount' => $refundAmount, + 'refund_amount' => $this->parseMoneyToAmount((string) $payload['money']), 'reason' => trim((string) ($payload['reason'] ?? '')), ]); - $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); - // 不同插件返回的退款结果字段不完全一致,这里仍按旧协议的退款参数重新组织一次。 - $pluginResult = $plugin->refund([ - 'order_id' => (string) $payOrder->pay_no, - 'pay_no' => (string) $payOrder->pay_no, - 'biz_no' => (string) $payOrder->biz_no, - 'chan_order_no' => (string) $payOrder->channel_order_no, - 'chan_trade_no' => (string) $payOrder->channel_trade_no, - 'out_trade_no' => (string) $payOrder->channel_order_no, - 'refund_no' => (string) $refundOrder->refund_no, - 'refund_amount' => $refundAmount, - 'refund_reason' => trim((string) ($payload['reason'] ?? '')), - 'extra' => (array) ($payOrder->ext_json ?? []), - ]); - - if (!$this->isPluginSuccess($pluginResult)) { - // 渠道明确失败时,先把退款单推进失败态,再把旧协议响应收口成失败文案。 - $this->refundService->markRefundFailed((string) $refundOrder->refund_no, [ - 'failed_at' => $this->now(), - 'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'), - 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), - ]); - - return ['code' => 0, 'msg' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败')]; + $refundOrder = $this->refundDispatchService->dispatch($refundOrder); + if ((int) $refundOrder->status !== TradeConstant::REFUND_STATUS_SUCCESS) { + return ['code' => self::FAILURE_CODE, 'msg' => (string) ($refundOrder->last_error ?: '退款失败')]; } - $this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [ - 'succeeded_at' => $this->now(), - 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), - ]); - - return ['code' => 1, 'msg' => '退款成功']; + return ['code' => self::SUCCESS_CODE, 'msg' => '退款成功']; } catch (Throwable $e) { - return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + return ['code' => self::FAILURE_CODE, 'msg' => $e->getMessage() ?: '请求失败']; } } /** * 预处理支付提交请求。 * - * 这里负责把旧协议载荷转换为当前支付单创建所需的数据结构。 - * * @param array $payload 请求载荷 * @param Request $request 请求对象 * @return array 预处理数据 */ - private function prepareSubmitAttempt(array $payload, Request $request): array + private function prepareSubmitAttempt(array $payload, Request $request, string $submitType): array { - // 先把旧协议载荷转换成当前系统的统一入参,再交给支付单主流程处理。 - $normalized = $this->normalizeSubmitPayload($payload, $request, false); + $merchantId = $this->authorizeSubmitMerchant($payload); + $paymentType = $this->paymentTypeService->findByCode((string) $payload['type']); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new ValidationException('支付方式不支持'); + } + + $normalized = $this->buildSubmitPayload($payload, $request, $merchantId, $submitType, $paymentType); $result = $this->payOrderService->preparePayAttempt($normalized); $payOrder = $result['pay_order']; $payParams = (array) ($result['pay_params'] ?? []); return [ - 'normalized_payload' => $normalized, - 'result' => $result, 'pay_order' => $payOrder, 'pay_params' => $payParams, + 'payment_result' => $result['payment_result'] ?? [], 'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no), ]; } @@ -393,97 +372,81 @@ class EpayV1ProtocolService extends BaseService * * @param array $payload 请求载荷 * @param Request $request 请求对象 - * @return array 预处理数据 + * @return string 收银台地址 */ - private function prepareCashierSubmit(array $payload, Request $request): array + private function prepareCashierSubmit(array $payload, Request $request): string { - $normalized = $this->normalizeSubmitPayload($payload, $request, true); + $merchantId = $this->authorizeSubmitMerchant($payload); + $normalized = $this->buildSubmitPayload($payload, $request, $merchantId, EpayProtocolConstant::SUBMIT_TYPE_PAGE); $result = $this->payOrderService->prepareCashierBizOrder($normalized); - return [ - 'normalized_payload' => $normalized, - 'result' => $result, - 'merchant' => $result['merchant'] ?? null, - 'biz_order' => $result['biz_order'] ?? null, - 'cashier_url' => (string) ($result['cashier_url'] ?? ''), - ]; + return (string) ($result['cashier_url'] ?? ''); } /** - * 归一化提交支付参数。 - * - * 这里会完成签名校验、金额转分、支付方式解析,并把旧协议字段写入扩展信息。 + * 认证 V1 提交商户并校验签名。 * * @param array $payload 请求载荷 - * @param Request $request 请求对象 - * @return array 当前支付单创建参数 - * @throws ValidationException + * @return int 商户ID */ - private function normalizeSubmitPayload(array $payload, Request $request, bool $allowEmptyType = false): array + private function authorizeSubmitMerchant(array $payload): int { - $merchantId = (int) ($payload['pid'] ?? 0); - if ($merchantId <= 0) { - throw new ValidationException('pid 参数不能为空'); - } - - $sign = trim((string) ($payload['sign'] ?? '')); - if ($sign === '') { - throw new ValidationException('sign 参数不能为空'); - } - + $merchantId = (int) $payload['pid']; $credential = $this->merchantApiCredentialService->findByMerchantId($merchantId); if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) { throw new ValidationException('商户 API 凭证未开通'); } - $signType = strtoupper((string) ($payload['sign_type'] ?? AuthConstant::API_SIGN_NAME_MD5)); - if ($signType !== AuthConstant::API_SIGN_NAME_MD5) { - throw new ValidationException('仅支持 MD5 签名'); - } - if (!$this->epaySignerManager->verify( $this->buildV1SignParams($payload), - $signType, - $sign, + strtoupper((string) $payload['sign_type']), + (string) $payload['sign'], (string) $credential->api_key )) { throw new ValidationException('签名验证失败'); } $this->merchantService->ensureMerchantEnabled($merchantId); - $typeCode = trim((string) ($payload['type'] ?? '')); - $merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? '')); - $subject = trim((string) ($payload['name'] ?? '')); - $amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0')); - $paymentType = null; - if ($typeCode === '') { - if (!$allowEmptyType) { - throw new ValidationException('type 参数不能为空'); - } - } else { - $paymentType = $this->resolveSubmitPaymentType($typeCode); - } + return $merchantId; + } - if ($merchantOrderNo === '') { - throw new ValidationException('out_trade_no 参数不能为空'); - } - if ($subject === '') { - throw new ValidationException('name 参数不能为空'); - } - if ($amount <= 0) { - throw new ValidationException('money 参数不合法'); + /** + * 构建当前支付单创建参数。 + * + * @param array $payload 请求载荷 + * @param Request $request 请求对象 + * @param int $merchantId 商户ID + * @param string $submitType 提交类型 + * @param PaymentType|null $paymentType 支付方式,收银台首屏为空 + * @return array + */ + private function buildSubmitPayload( + array $payload, + Request $request, + int $merchantId, + string $submitType, + ?PaymentType $paymentType = null + ): array { + $orderPayload = $payload; + if ($submitType === EpayProtocolConstant::SUBMIT_TYPE_PAGE) { + $orderPayload['device'] = $this->submitPayloadAssembler->resolvePageSubmitDevice( + $payload, + $request, + EpayProtocolConstant::v1Devices() + ); } // 旧协议的展示字段统一交给 assembler,避免 submit / mapi / cashier 三处口径漂移。 - $orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [ - '_protocol_version' => 'v1', + $orderFields = $this->submitPayloadAssembler->buildOrderFields($orderPayload, $request, [ + '_protocol_version' => EpayProtocolConstant::VERSION_V1, + '_submit_type' => $submitType, ]); $normalized = [ - 'merchant_id' => (int) ($payload['pid'] ?? 0), - 'merchant_order_no' => $merchantOrderNo, - 'pay_amount' => $amount, + 'merchant_id' => $merchantId, + 'merchant_order_no' => trim((string) $payload['out_trade_no']), + 'pay_amount' => $this->parseMoneyToAmount((string) $payload['money']), 'subject' => (string) $orderFields['subject'], 'body' => (string) $orderFields['body'], 'notify_url' => (string) $orderFields['notify_url'], @@ -520,26 +483,6 @@ class EpayV1ProtocolService extends BaseService return $params; } - /** - * 解析提交支付方式。 - * - * 只接受显式传入且启用中的支付方式。 - * - * @param string $typeCode 支付方式编码 - * @return PaymentType 支付方式模型 - * @throws ValidationException - */ - private function resolveSubmitPaymentType(string $typeCode): PaymentType - { - $typeCode = trim($typeCode); - $paymentType = $this->paymentTypeService->findByCode($typeCode); - if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { - throw new ValidationException('支付方式不支持'); - } - - return $paymentType; - } - /** * 构建旧版 MAPI 返回结构。 * @@ -553,53 +496,49 @@ class EpayV1ProtocolService extends BaseService /** @var PayOrder $payOrder */ $payOrder = $attempt['pay_order']; $payParams = (array) ($attempt['pay_params'] ?? []); - $normalizedPayload = (array) ($attempt['normalized_payload'] ?? []); + $paymentResult = (array) ($attempt['payment_result'] ?? []); $paymentPageUrl = (string) ($attempt['payment_page_url'] ?? $this->buildPaymentPageUrl((string) $payOrder->pay_no)); $payNo = (string) $payOrder->pay_no; - $response = ['code' => 1, 'msg' => '提交成功', 'trade_no' => $payNo]; - $device = strtolower(trim((string) ($normalizedPayload['device'] ?? ''))); - $type = strtolower(trim((string) ($payParams['type'] ?? ''))); - $resolved = $this->resolveV1PayResponse($payParams, $device, $paymentPageUrl, $type); + $response = ['code' => self::SUCCESS_CODE, 'msg' => '提交成功', 'trade_no' => $payNo]; + $device = strtolower(trim((string) ($payOrder->device ?? ''))); + $type = strtolower(trim((string) ($paymentResult['pay_page'] ?? ''))); - if ($resolved['field'] !== '') { - $response[$resolved['field']] = $resolved['value']; - } - - return $response; - } - - /** - * 解析旧版 MAPI 的单字段返回体。 - * - * 旧协议同一时刻只会返回 `payurl`、`qrcode`、`urlscheme` 中的一个。 - * - * @param array $payParams 插件返回参数 - * @param string $device 请求设备 - * @param string $paymentPageUrl 支付页地址 - * @param string $type 插件返回类型 - * @return array{field: string, value: string} - */ - private function resolveV1PayResponse(array $payParams, string $device, string $paymentPageUrl, string $type): array - { if ($device === 'jump') { - return ['field' => 'payurl', 'value' => $paymentPageUrl]; + $response['payurl'] = $paymentPageUrl; + + return $response; } if ($type === 'qrcode') { - $qrcode = $this->stringifyValue($payParams['qrcode_url'] ?? ''); + $qrcode = $this->stringifyValue($payParams['qrcode'] ?? ''); if ($qrcode !== '') { - return ['field' => 'qrcode', 'value' => $qrcode]; + $response['qrcode'] = $qrcode; + + return $response; } } - if ($type === 'jsapi') { - $urlscheme = $this->stringifyValue($payParams['order_string'] ?? ''); + if ($type === 'jump') { + $url = $this->stringifyValue($payParams['url'] ?? ''); + if ($url !== '') { + $response['payurl'] = $url; + + return $response; + } + } + + if ($type === 'urlscheme') { + $urlscheme = $this->stringifyValue($payParams['urlscheme'] ?? ''); if ($urlscheme !== '') { - return ['field' => 'urlscheme', 'value' => $urlscheme]; + $response['urlscheme'] = $urlscheme; + + return $response; } } - return ['field' => 'payurl', 'value' => $paymentPageUrl]; + $response['payurl'] = $paymentPageUrl; + + return $response; } /** @@ -617,13 +556,13 @@ class EpayV1ProtocolService extends BaseService return [ 'trade_no' => (string) $payOrder->pay_no, - 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? $extJson['merchant_order_no'] ?? ''), + 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''), 'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''), - 'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id), + 'type' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id), 'pid' => (int) $payOrder->merchant_id, 'addtime' => FormatHelper::dateTime($payOrder->created_at), 'endtime' => FormatHelper::dateTime($payOrder->paid_at), - 'name' => (string) ($bizOrder?->subject ?? $extJson['subject'] ?? ''), + 'name' => (string) ($bizOrder?->subject ?? ''), 'money' => FormatHelper::amount((int) $payOrder->pay_amount), 'status' => (int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS ? 1 : 0, 'param' => $this->stringifyValue($merchantExt['param'] ?? ''), @@ -676,17 +615,6 @@ class EpayV1ProtocolService extends BaseService return ['pay_order' => $payOrder, 'biz_order' => $bizOrder]; } - /** - * 根据支付方式 ID 解析支付方式编码。 - * - * @param int $payTypeId 支付方式ID - * @return string 支付方式编码 - */ - private function resolvePaymentTypeCode(int $payTypeId): string - { - return $this->paymentTypeService->resolveCodeById($payTypeId); - } - /** * 将元金额转成分。 * @@ -707,17 +635,6 @@ class EpayV1ProtocolService extends BaseService return ((int) $integer) * 100 + (int) substr($fraction, 0, 2); } - /** - * 规范化异常提示。 - * - * @param Throwable $e 异常对象 - * @return string 错误提示 - */ - private function normalizeErrorMessage(Throwable $e): string - { - return $e->getMessage() !== '' ? $e->getMessage() : '请求失败'; - } - /** * 构建支付页地址。 * @@ -729,113 +646,6 @@ class EpayV1ProtocolService extends BaseService return rtrim((string) sys_config('site_url'), '/') . '/payment/' . rawurlencode($payNo); } - /** - * 按支付载体生成浏览器响应。 - * - * 页面跳转支付允许直接重定向或直接输出 HTML,其余载体统一回到平台支付页。 - * - * @param array $attempt 支付尝试结果 - * @return Response - */ - private function buildBrowserSubmitResponse(array $attempt): Response - { - $payParams = (array) ($attempt['pay_params'] ?? []); - $payType = strtolower(trim((string) ($payParams['type'] ?? ''))); - $paymentPageUrl = (string) ($attempt['payment_page_url'] ?? ''); - - if (in_array($payType, ['jump', 'url', 'web', 'h5'], true)) { - $jumpUrl = $this->resolveBrowserPayUrl($payParams); - if ($jumpUrl !== '') { - return redirect($jumpUrl); - } - } - - if (in_array($payType, ['html', 'form'], true)) { - $html = $this->resolveBrowserHtml($payParams); - if ($html !== '') { - return response($html, 200, [ - 'Content-Type' => 'text/html; charset=utf-8', - ]); - } - } - - if ($paymentPageUrl === '') { - throw new ValidationException('支付页跳转地址生成失败'); - } - - return redirect($paymentPageUrl); - } - - /** - * 提取浏览器跳转地址。 - * - * @param array $payParams 支付参数 - * @return string - */ - private function resolveBrowserPayUrl(array $payParams): string - { - foreach (['payurl', 'pay_url', 'url', 'redirect_url', 'mweb_url'] as $key) { - $value = $this->stringifyValue($payParams[$key] ?? ''); - if ($value !== '') { - return $value; - } - } - - return ''; - } - - /** - * 提取浏览器可直接渲染的 HTML。 - * - * @param array $payParams 支付参数 - * @return string - */ - private function resolveBrowserHtml(array $payParams): string - { - foreach (['html', 'html_form', 'form_html'] as $key) { - $value = $payParams[$key] ?? null; - if (is_string($value) && trim($value) !== '') { - return $value; - } - } - - return ''; - } - - /** - * 判断插件返回的 success 标记。 - * - * 如果插件未显式返回 `success`,则默认视为成功。 - * - * @param array $pluginResult 插件结果 - * @return bool 插件是否通过 - */ - private function isPluginSuccess(array $pluginResult): bool - { - return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success']; - } - - /** - * 解析退款渠道单号。 - * - * @param array $pluginResult 插件结果 - * @param string $default 默认值 - * @return string 渠道退款单号 - */ - private function resolveRefundChannelNo(array $pluginResult, string $default = ''): string - { - foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) { - if (array_key_exists($key, $pluginResult)) { - $value = $this->stringifyValue($pluginResult[$key]); - if ($value !== '') { - return $value; - } - } - } - - return $default; - } - /** * 将任意值规范化为字符串。 * diff --git a/app/service/payment/epay/EpayV2ProtocolService.php b/app/service/payment/epay/EpayV2ProtocolService.php index c46a2d9..00ef9f0 100644 --- a/app/service/payment/epay/EpayV2ProtocolService.php +++ b/app/service/payment/epay/EpayV2ProtocolService.php @@ -5,10 +5,9 @@ namespace app\service\payment\epay; use app\common\base\BaseService; use app\common\constant\AuthConstant; use app\common\constant\CommonConstant; -use app\common\constant\RouteConstant; +use app\common\constant\EpayProtocolConstant; use app\common\constant\TradeConstant; use app\common\util\FormatHelper; -use app\exception\ConflictException; use app\exception\ResourceNotFoundException; use app\exception\ValidationException; use app\model\merchant\Merchant; @@ -23,8 +22,7 @@ use app\repository\payment\trade\RefundOrderRepository; use app\service\merchant\MerchantService; use app\service\payment\order\PayOrderQueryService; use app\service\payment\order\PayOrderService; -use app\service\payment\order\PaymentOrderInputAssembler; -use app\service\payment\order\RefundQueryService; +use app\service\payment\order\RefundDispatchService; use app\service\payment\order\RefundService; use app\service\payment\transfer\TransferService; use app\service\payment\runtime\PaymentPluginManager; @@ -35,9 +33,34 @@ use Throwable; /** * ePay V2 协议服务。 + * + * 负责 V2 签名鉴权、支付、退款、查单、商户信息和转账接口的协议适配。 */ class EpayV2ProtocolService extends BaseService { + private const SUCCESS_CODE = 0; + private const FAILURE_CODE = 1; + + /** + * 构造方法。 + * + * @param MerchantService $merchantService 商户服务 + * @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库 + * @param PaymentTypeService $paymentTypeService 支付类型服务 + * @param PayOrderService $payOrderService 支付订单服务 + * @param PayOrderQueryService $payOrderQueryService 支付订单查询服务 + * @param RefundService $refundService 退款服务 + * @param PayOrderRepository $payOrderRepository 支付单仓库 + * @param BizOrderRepository $bizOrderRepository 业务单仓库 + * @param RefundOrderRepository $refundOrderRepository 退款单仓库 + * @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库 + * @param PaymentPluginManager $paymentPluginManager 支付插件管理器 + * @param TransferService $transferService 转账服务 + * @param EpaySignerManager $signerManager ePay 签名管理器 + * @param EpaySubmitPayloadAssembler $submitPayloadAssembler 提交入参组装器 + * @param RefundDispatchService $refundDispatchService 退款派发服务 + * @return void + */ public function __construct( protected MerchantService $merchantService, protected MerchantApiCredentialRepository $merchantApiCredentialRepository, @@ -45,7 +68,6 @@ class EpayV2ProtocolService extends BaseService protected PayOrderService $payOrderService, protected PayOrderQueryService $payOrderQueryService, protected RefundService $refundService, - protected RefundQueryService $refundQueryService, protected PayOrderRepository $payOrderRepository, protected BizOrderRepository $bizOrderRepository, protected RefundOrderRepository $refundOrderRepository, @@ -53,18 +75,25 @@ class EpayV2ProtocolService extends BaseService protected PaymentPluginManager $paymentPluginManager, protected TransferService $transferService, protected EpaySignerManager $signerManager, - protected PaymentOrderInputAssembler $orderInputAssembler + protected EpaySubmitPayloadAssembler $submitPayloadAssembler, + protected RefundDispatchService $refundDispatchService ) { } + /** + * 处理 V2 页面支付入口。 + * + * @param array $payload 请求载荷 + * @param Request $request 请求对象 + * @return Response 跳转响应或签名 JSON 响应 + */ public function submit(array $payload, Request $request): Response { try { $typeCode = trim((string) ($payload['type'] ?? '')); if ($typeCode === '') { // `type` 为空时先回收银台,显式选完方式后再创建支付单。 - $attempt = $this->prepareCashierSubmit($payload, $request); - $cashierUrl = (string) ($attempt['cashier_url'] ?? ''); + $cashierUrl = $this->prepareCashierSubmit($payload, $request); if ($cashierUrl === '') { throw new ValidationException('收银台跳转地址生成失败'); } @@ -72,32 +101,74 @@ class EpayV2ProtocolService extends BaseService return redirect($cashierUrl); } - return $this->buildBrowserSubmitResponse($this->preparePayAttempt($payload, $request, false)); + $attempt = $this->preparePayAttempt( + $payload, + $request, + EpayProtocolConstant::SUBMIT_TYPE_PAGE + ); + + return redirect((string) $attempt['payment_page_url']); } catch (Throwable $e) { + $data = method_exists($e, 'getData') ? $e->getData() : []; + $payNo = trim((string) ($data['pay_no'] ?? '')); + if ($payNo !== '') { + return redirect($this->buildPaymentPageUrl($payNo)); + } + return json($this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ])); } } + /** + * 处理 V2 API 支付下单入口。 + * + * @param array $payload 请求载荷 + * @param Request $request 请求对象 + * @return array 签名后的 V2 响应 + */ public function create(array $payload, Request $request): array { try { - $attempt = $this->preparePayAttempt($payload, $request, true); - return $this->signResponse($this->buildCreateResponse($attempt)); + $attempt = $this->preparePayAttempt( + $payload, + $request, + EpayProtocolConstant::SUBMIT_TYPE_API + ); + /** @var PayOrder $payOrder */ + $payOrder = $attempt['pay_order']; + $paymentResult = (array) ($attempt['payment_result'] ?? []); + $payPage = strtolower(trim((string) ($paymentResult['pay_page'] ?? ''))); + $payAction = strtolower(trim((string) ($paymentResult['pay_action'] ?? $payPage))); + $payParams = (array) ($attempt['pay_params'] ?? []); + + return $this->signResponse([ + 'code' => self::SUCCESS_CODE, + 'msg' => 'success', + 'trade_no' => (string) $payOrder->pay_no, + 'pay_type' => $payAction, + 'pay_info' => $this->buildCreatePayInfo($payPage, $payParams), + ]); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 查询 V2 支付订单。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function query(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $context = $this->resolvePayOrderContext((int) $merchant->id, $payload); if (!$context) { throw new ResourceNotFoundException('订单不存在'); @@ -106,16 +177,22 @@ class EpayV2ProtocolService extends BaseService return $this->signResponse($this->buildOrderResponse($context['pay_order'], $context['biz_order'])); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 创建并同步派发 V2 退款。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function refund(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $context = $this->resolvePayOrderContext((int) $merchant->id, $payload); if (!$context) { throw new ResourceNotFoundException('订单不存在'); @@ -123,62 +200,39 @@ class EpayV2ProtocolService extends BaseService /** @var PayOrder $payOrder */ $payOrder = $context['pay_order']; - $refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0')); - if ($refundAmount <= 0) { - throw new ValidationException('money 参数不合法'); - } - $merchantRefundNo = trim((string) ($payload['out_refund_no'] ?? $payload['refund_no'] ?? '')); $refundOrder = $this->refundService->createRefund([ 'pay_no' => (string) $payOrder->pay_no, 'merchant_refund_no' => $merchantRefundNo, - 'refund_amount' => $refundAmount, + 'refund_amount' => $this->parseMoneyToAmount((string) $payload['money']), 'reason' => trim((string) ($payload['reason'] ?? '')), ]); - $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); - $pluginResult = $plugin->refund([ - 'order_id' => (string) $payOrder->pay_no, - 'pay_no' => (string) $payOrder->pay_no, - 'biz_no' => (string) $payOrder->biz_no, - 'chan_order_no' => (string) $payOrder->channel_order_no, - 'chan_trade_no' => (string) $payOrder->channel_trade_no, - 'out_trade_no' => (string) $payOrder->channel_order_no, - 'refund_no' => (string) $refundOrder->refund_no, - 'refund_amount' => $refundAmount, - 'refund_reason' => trim((string) ($payload['reason'] ?? '')), - 'extra' => (array) ($payOrder->ext_json ?? []), - ]); - - if (!$this->isPluginSuccess($pluginResult)) { - $this->refundService->markRefundFailed((string) $refundOrder->refund_no, [ - 'failed_at' => $this->now(), - 'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'), - 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), - ]); - - throw new ValidationException((string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败')); + $refundOrder = $this->refundDispatchService->dispatch($refundOrder); + if ((int) $refundOrder->status !== TradeConstant::REFUND_STATUS_SUCCESS) { + throw new ValidationException((string) ($refundOrder->last_error ?: '退款失败')); } - $this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [ - 'succeeded_at' => $this->now(), - 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), - ]); - $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); return $this->signResponse($this->buildRefundResponse($refundOrder->refresh(), $payOrder->refresh(), $bizOrder)); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 查询 V2 退款单。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function refundQuery(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $refundOrder = $this->resolveRefundOrder((int) $merchant->id, $payload); $payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no); $bizOrder = $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no); @@ -186,16 +240,22 @@ class EpayV2ProtocolService extends BaseService return $this->signResponse($this->buildRefundResponse($refundOrder, $payOrder, $bizOrder)); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 关闭 V2 支付订单。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function close(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $context = $this->resolvePayOrderContext((int) $merchant->id, $payload); if (!$context) { throw new ResourceNotFoundException('订单不存在'); @@ -206,7 +266,7 @@ class EpayV2ProtocolService extends BaseService $currentStatus = (int) $payOrder->status; if ($currentStatus === TradeConstant::ORDER_STATUS_CLOSED) { return $this->signResponse([ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', ]); } @@ -230,7 +290,7 @@ class EpayV2ProtocolService extends BaseService 'extra' => (array) ($payOrder->ext_json ?? []), ]); - if (!$this->isPluginSuccess($pluginResult)) { + if (array_key_exists('success', $pluginResult) && !(bool) $pluginResult['success']) { throw new ValidationException((string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '渠道关单失败')); } @@ -238,32 +298,33 @@ class EpayV2ProtocolService extends BaseService $this->payOrderService->closePayOrder((string) $payOrder->pay_no, [ 'closed_at' => $this->now(), 'reason' => $closeReason, - 'ext_json' => [ - 'plugin' => [ - 'close_result' => $pluginResult, - ], - ], ]); return $this->signResponse([ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', ]); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 查询 V2 商户账户和订单概览。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function merchantInfo(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $account = $this->merchantAccountRepository->findByMerchantId((int) $merchant->id); - $today = $this->nowDate(); - $yesterday = $this->yesterdayDate(); + $today = FormatHelper::timestamp(time(), 'Y-m-d'); + $yesterday = FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d'); $orderQuery = $this->payOrderRepository->query()->where('merchant_id', (int) $merchant->id); @@ -274,7 +335,7 @@ class EpayV2ProtocolService extends BaseService $yesterdayMoney = (int) (clone $orderQuery)->whereDate('created_at', $yesterday)->sum('pay_amount'); return $this->signResponse([ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', 'pid' => (int) $merchant->id, 'status' => (int) $merchant->status, @@ -292,18 +353,24 @@ class EpayV2ProtocolService extends BaseService ]); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 查询 V2 商户订单列表。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function merchantOrders(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); - $limit = min(50, max(1, (int) ($payload['limit'] ?? 20))); - $offset = max(0, (int) ($payload['offset'] ?? 0)); + $merchant = $this->authorizeMerchant($payload); + $limit = (int) ($payload['limit'] ?? 20); + $offset = (int) ($payload['offset'] ?? 0); $page = (int) floor($offset / $limit) + 1; $filters = []; if (array_key_exists('status', $payload) && $payload['status'] !== '') { @@ -313,26 +380,32 @@ class EpayV2ProtocolService extends BaseService $result = $this->payOrderQueryService->paginate($filters, $page, $limit, (int) $merchant->id); return $this->signResponse([ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', 'data' => $result['list'] ?? [], ]); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 发起 V2 转账。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function transferSubmit(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $data = $this->transferService->submit($merchant, $payload); return $this->signResponse([ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', 'status' => (int) ($data['status'] ?? 0), 'biz_no' => (string) ($data['biz_no'] ?? ''), @@ -343,44 +416,56 @@ class EpayV2ProtocolService extends BaseService ]); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 查询 V2 转账单。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function transferQuery(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $data = $this->transferService->query($merchant, $payload); return $this->signResponse([ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', ] + $data); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } + /** + * 查询 V2 转账余额。 + * + * @param array $payload 请求载荷 + * @return array 签名后的 V2 响应 + */ public function transferBalance(array $payload): array { try { - $merchant = $this->authorizeMerchant($payload, true); + $merchant = $this->authorizeMerchant($payload); $data = $this->transferService->balance($merchant); return $this->signResponse([ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', ] + $data); } catch (Throwable $e) { return $this->signResponse([ - 'code' => $this->resolveFailureCode($e), - 'msg' => $this->normalizeErrorMessage($e), + 'code' => self::FAILURE_CODE, + 'msg' => $e->getMessage() ?: '请求失败', ]); } } @@ -390,40 +475,39 @@ class EpayV2ProtocolService extends BaseService * * @param array $payload 请求参数 * @param Request $request 请求对象 - * @param bool $requireType 是否强制要求 type * @return array */ - private function preparePayAttempt(array $payload, Request $request, bool $requireType): array + private function preparePayAttempt( + array $payload, + Request $request, + string $submitType + ): array { - $merchant = $this->authorizeMerchant($payload, true); - $typeCode = trim((string) ($payload['type'] ?? '')); - if ($requireType && $typeCode === '') { - throw new ValidationException('type 不能为空'); + $merchant = $this->authorizeMerchant($payload); + $paymentType = $this->paymentTypeService->findByCode((string) $payload['type']); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new ValidationException('支付方式不支持'); } - $paymentType = $this->resolvePaymentType($typeCode); - $merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? '')); - $subject = trim((string) ($payload['name'] ?? '')); - $amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0')); - if ($merchantOrderNo === '') { - throw new ValidationException('out_trade_no 不能为空'); - } - if ($subject === '') { - throw new ValidationException('name 不能为空'); - } - if ($amount <= 0) { - throw new ValidationException('money 参数不合法'); + $orderPayload = $payload; + if ($submitType === EpayProtocolConstant::SUBMIT_TYPE_PAGE) { + $orderPayload['device'] = $this->submitPayloadAssembler->resolvePageSubmitDevice( + $payload, + $request, + EpayProtocolConstant::v2Devices() + ); } - // V2 直连支付和收银台确认共用同一套字段归一化逻辑。 - $orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [ - '_protocol_version' => 'v2', + // V2 协议入口在这里统一转换为支付发起服务的标准入参。 + $orderFields = $this->submitPayloadAssembler->buildOrderFields($orderPayload, $request, [ + '_protocol_version' => EpayProtocolConstant::VERSION_V2, + '_submit_type' => $submitType, ]); $normalized = [ 'merchant_id' => (int) $merchant->id, - 'merchant_order_no' => $merchantOrderNo, + 'merchant_order_no' => trim((string) $payload['out_trade_no']), 'pay_type_id' => (int) $paymentType->id, - 'pay_amount' => $amount, + 'pay_amount' => $this->parseMoneyToAmount((string) $payload['money']), 'subject' => (string) $orderFields['subject'], 'body' => (string) $orderFields['body'], 'notify_url' => (string) $orderFields['notify_url'], @@ -438,7 +522,6 @@ class EpayV2ProtocolService extends BaseService $payOrder = $attempt['pay_order']; return [ - 'merchant' => $merchant, 'pay_order' => $payOrder, 'payment_result' => $attempt['payment_result'] ?? [], 'pay_params' => $attempt['pay_params'] ?? [], @@ -451,32 +534,28 @@ class EpayV2ProtocolService extends BaseService * * @param array $payload 请求参数 * @param Request $request 请求对象 - * @return array + * @return string 收银台地址 */ - private function prepareCashierSubmit(array $payload, Request $request): array + private function prepareCashierSubmit(array $payload, Request $request): string { - $merchant = $this->authorizeMerchant($payload, true); - $merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? '')); - $subject = trim((string) ($payload['name'] ?? '')); - $amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0')); - if ($merchantOrderNo === '') { - throw new ValidationException('out_trade_no 不能为空'); - } - if ($subject === '') { - throw new ValidationException('name 不能为空'); - } - if ($amount <= 0) { - throw new ValidationException('money 参数不合法'); - } + $merchant = $this->authorizeMerchant($payload); + + $orderPayload = $payload; + $orderPayload['device'] = $this->submitPayloadAssembler->resolvePageSubmitDevice( + $payload, + $request, + EpayProtocolConstant::v2Devices() + ); // 收银台首屏只需要业务单上下文,不在这里创建支付单。 - $orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [ - '_protocol_version' => 'v2', + $orderFields = $this->submitPayloadAssembler->buildOrderFields($orderPayload, $request, [ + '_protocol_version' => EpayProtocolConstant::VERSION_V2, + '_submit_type' => EpayProtocolConstant::SUBMIT_TYPE_PAGE, ]); $normalized = [ 'merchant_id' => (int) $merchant->id, - 'merchant_order_no' => $merchantOrderNo, - 'pay_amount' => $amount, + 'merchant_order_no' => trim((string) $payload['out_trade_no']), + 'pay_amount' => $this->parseMoneyToAmount((string) $payload['money']), 'subject' => (string) $orderFields['subject'], 'body' => (string) $orderFields['body'], 'notify_url' => (string) $orderFields['notify_url'], @@ -488,33 +567,27 @@ class EpayV2ProtocolService extends BaseService $result = $this->payOrderService->prepareCashierBizOrder($normalized); - return [ - 'merchant' => $merchant, - 'biz_order' => $result['biz_order'] ?? null, - 'cashier_url' => (string) ($result['cashier_url'] ?? ''), - ]; + return (string) ($result['cashier_url'] ?? ''); } /** - * 构建创建支付响应。 + * V2 API 返回协议字段,不能暴露承接页内部的 `page/_page` 结构。 * - * @param array $attempt 支付尝试结果 - * @return array + * @param string $payPage 承接页类型 + * @param array $payParams 插件支付参数 + * @return mixed V2 pay_info */ - private function buildCreateResponse(array $attempt): array + private function buildCreatePayInfo(string $payPage, array $payParams): mixed { - /** @var PayOrder $payOrder */ - $payOrder = $attempt['pay_order']; - $payParams = (array) ($attempt['pay_params'] ?? []); - $paymentResult = (array) ($attempt['payment_result'] ?? []); - - return [ - 'code' => $this->successCode(), - 'msg' => 'success', - 'trade_no' => (string) $payOrder->pay_no, - 'pay_type' => strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? 'qrcode'))), - 'pay_info' => $payParams, - ]; + return match ($payPage) { + 'qrcode' => (string) ($payParams['qrcode'] ?? ''), + 'html' => (string) ($payParams['html'] ?? ''), + 'jump' => (string) ($payParams['url'] ?? ''), + 'urlscheme' => (string) ($payParams['urlscheme'] ?? ''), + 'jsapi' => array_diff_key($payParams, ['raw' => true]), + 'page' => $payParams['params'] ?? array_diff_key($payParams, ['_page' => true, 'raw' => true]), + default => array_diff_key($payParams, ['raw' => true]), + }; } /** @@ -580,16 +653,12 @@ class EpayV2ProtocolService extends BaseService return $refundOrder; } - if ($outRefundNo !== '') { - $refundOrder = $this->refundOrderRepository->findByMerchantRefundNo($merchantId, $outRefundNo); - if (!$refundOrder) { - throw new ResourceNotFoundException('退款单不存在', ['out_refund_no' => $outRefundNo]); - } - - return $refundOrder; + $refundOrder = $this->refundOrderRepository->findByMerchantRefundNo($merchantId, $outRefundNo); + if (!$refundOrder) { + throw new ResourceNotFoundException('退款单不存在', ['out_refund_no' => $outRefundNo]); } - throw new ValidationException('refund_no/out_refund_no 不能为空'); + return $refundOrder; } /** @@ -607,13 +676,13 @@ class EpayV2ProtocolService extends BaseService $refundAmount = (int) ($bizOrder?->refund_amount ?? 0); return [ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', 'trade_no' => (string) $payOrder->pay_no, 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''), 'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''), - 'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id), - 'status' => $this->resolveEpayOrderStatus($payOrder, $refundAmount), + 'type' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id), + 'status' => (int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS ? ($refundAmount > 0 ? 2 : 1) : 0, 'pid' => (int) $payOrder->merchant_id, 'addtime' => FormatHelper::dateTime($payOrder->created_at), 'endtime' => FormatHelper::dateTime($payOrder->paid_at), @@ -640,7 +709,7 @@ class EpayV2ProtocolService extends BaseService $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no); return [ - 'code' => $this->successCode(), + 'code' => self::SUCCESS_CODE, 'msg' => 'success', 'refund_no' => (string) $refundOrder->refund_no, 'out_refund_no' => (string) $refundOrder->merchant_refund_no, @@ -653,64 +722,15 @@ class EpayV2ProtocolService extends BaseService ]; } - /** - * 解析支付方式。 - * - * @param string $typeCode 支付方式编码 - * @return \app\model\payment\PaymentType - */ - private function resolvePaymentType(string $typeCode) - { - $typeCode = trim($typeCode); - $paymentType = $this->paymentTypeService->findByCode($typeCode); - if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { - throw new ValidationException('支付方式不支持'); - } - - return $paymentType; - } - - /** - * 根据支付方式 ID 解析支付方式编码。 - * - * @param int $payTypeId 支付方式ID - * @return string - */ - private function resolvePaymentTypeCode(int $payTypeId): string - { - return $this->paymentTypeService->resolveCodeById($payTypeId); - } - - /** - * 计算 ePay 查询状态。 - * - * @param PayOrder $payOrder 支付单 - * @param int $refundAmount 已退款金额 - * @return int - */ - private function resolveEpayOrderStatus(PayOrder $payOrder, int $refundAmount): int - { - if ((int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS) { - return $refundAmount > 0 ? 2 : 1; - } - - return 0; - } - /** * 认证商户并校验请求签名。 * * @param array $payload 请求参数 - * @param bool $verifySignature 是否验签 * @return Merchant */ - private function authorizeMerchant(array $payload, bool $verifySignature): Merchant + private function authorizeMerchant(array $payload): Merchant { - $merchantId = (int) ($payload['pid'] ?? 0); - if ($merchantId <= 0) { - throw new ValidationException('pid 不能为空'); - } - + $merchantId = (int) $payload['pid']; $merchant = $this->merchantService->ensureMerchantEnabled($merchantId); $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) { @@ -722,24 +742,15 @@ class EpayV2ProtocolService extends BaseService throw new ValidationException('商户 RSA 公钥未配置'); } - if ($verifySignature) { - $timestamp = (int) ($payload['timestamp'] ?? 0); - if ($timestamp <= 0 || abs(time() - $timestamp) > (int) config('epay.v2.timestamp_ttl', 300)) { - throw new ValidationException('timestamp 校验失败'); - } + if (abs(time() - (int) $payload['timestamp']) > (int) config('epay.v2.timestamp_ttl', 300)) { + throw new ValidationException('timestamp 校验失败'); + } - $sign = trim((string) ($payload['sign'] ?? '')); - if ($sign === '') { - throw new ValidationException('sign 不能为空'); - } + $verifyPayload = $payload; + unset($verifyPayload['sign'], $verifyPayload['sign_type']); - $signType = $this->signerManager->normalizeSignType((string) ($payload['sign_type'] ?? AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA)); - $verifyPayload = $payload; - unset($verifyPayload['sign'], $verifyPayload['sign_type']); - - if (!$this->signerManager->verify($verifyPayload, $signType, $sign, $publicKey)) { - throw new ValidationException('签名验证失败'); - } + if (!$this->signerManager->verify($verifyPayload, (string) $payload['sign_type'], (string) $payload['sign'], $publicKey)) { + throw new ValidationException('签名验证失败'); } return $merchant; @@ -754,7 +765,7 @@ class EpayV2ProtocolService extends BaseService private function signResponse(array $data): array { $data['timestamp'] = (string) ($data['timestamp'] ?? time()); - $data['sign_type'] = $this->resolveResponseSignType(); + $data['sign_type'] = AuthConstant::API_SIGN_NAME_RSA; $privateKey = trim((string) config('epay.v2.platform_private_key', '')); if ($privateKey === '') { throw new ValidationException('平台 RSA 私钥未配置'); @@ -767,60 +778,6 @@ class EpayV2ProtocolService extends BaseService return $data; } - /** - * 解析响应签名类型。 - * - * 响应始终回写文档约定的规范值,避免把内部别名暴露给商户。 - * - * @return string - */ - private function resolveResponseSignType(): string - { - $signType = $this->signerManager->normalizeSignType((string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA)); - - return match ($signType) { - AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - AuthConstant::API_SIGN_NAME_MD5 => AuthConstant::API_SIGN_NAME_MD5, - default => AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, - }; - } - - /** - * 规范化错误信息。 - * - * @param Throwable $e 异常 - * @return string - */ - private function normalizeErrorMessage(Throwable $e): string - { - return $e->getMessage() ?: '请求失败'; - } - - /** - * V2 协议成功码。 - * - * @return int - */ - private function successCode(): int - { - return 0; - } - - /** - * 解析 V2 失败码。 - * - * 文档只约定 `0` 为成功,其它值为失败;这里优先保留异常业务码,缺失时回退到 `1`。 - * - * @param Throwable|null $e 异常对象 - * @return int - */ - private function resolveFailureCode(?Throwable $e = null): int - { - $code = (int) ($e?->getCode() ?? 0); - - return $code === $this->successCode() ? 1 : $code; - } - /** * 金额字符串转分。 * @@ -860,93 +817,6 @@ class EpayV2ProtocolService extends BaseService return trim((string) $value); } - /** - * 获取当前日期。 - * - * @return string - */ - private function nowDate(): string - { - return FormatHelper::timestamp(time(), 'Y-m-d'); - } - - /** - * 获取昨日日期。 - * - * @return string - */ - private function yesterdayDate(): string - { - return FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d'); - } - - /** - * 解析插件退款渠道单号。 - * - * @param array $pluginResult 插件结果 - * @return string - */ - private function resolveRefundChannelNo(array $pluginResult): string - { - foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) { - $value = $this->stringifyValue($pluginResult[$key] ?? ''); - if ($value !== '') { - return $value; - } - } - - return ''; - } - - /** - * 判断插件是否成功。 - * - * @param array $pluginResult 插件结果 - * @return bool - */ - private function isPluginSuccess(array $pluginResult): bool - { - return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success']; - } - - /** - * 按支付载体生成浏览器响应。 - * - * 页面跳转支付允许直接返回渠道跳转页或 HTML,其余情况回到平台支付页承载。 - * - * @param array $attempt 支付尝试结果 - * @return Response - */ - private function buildBrowserSubmitResponse(array $attempt): Response - { - $payParams = (array) ($attempt['pay_params'] ?? []); - $paymentResult = (array) ($attempt['payment_result'] ?? []); - $payType = strtolower(trim((string) ($payParams['type'] ?? $paymentResult['pay_type'] ?? ''))); - $paymentPageUrl = (string) ($attempt['payment_page_url'] ?? ''); - - if (in_array($payType, ['jump', 'url', 'web', 'h5'], true)) { - $jumpUrl = $this->resolveBrowserPayUrl($payParams); - if ($jumpUrl !== '') { - return redirect($jumpUrl); - } - } - - if (in_array($payType, ['html', 'form'], true)) { - $html = $this->resolveBrowserHtml($payParams); - if ($html !== '') { - return response($html, 200, [ - 'Content-Type' => 'text/html; charset=utf-8', - ]); - } - } - - if ($paymentPageUrl === '') { - throw new ValidationException('支付页跳转地址生成失败'); - } - - return redirect($paymentPageUrl); - } - /** * 构建支付页地址。 * @@ -958,39 +828,4 @@ class EpayV2ProtocolService extends BaseService return rtrim((string) sys_config('site_url'), '/') . '/payment/' . rawurlencode($payNo); } - /** - * 提取浏览器跳转地址。 - * - * @param array $payParams 支付参数 - * @return string - */ - private function resolveBrowserPayUrl(array $payParams): string - { - foreach (['payurl', 'pay_url', 'url', 'redirect_url', 'mweb_url'] as $key) { - $value = $this->stringifyValue($payParams[$key] ?? ''); - if ($value !== '') { - return $value; - } - } - - return ''; - } - - /** - * 提取浏览器可直接渲染的 HTML。 - * - * @param array $payParams 支付参数 - * @return string - */ - private function resolveBrowserHtml(array $payParams): string - { - foreach (['html', 'html_form', 'form_html'] as $key) { - $value = $payParams[$key] ?? null; - if (is_string($value) && trim($value) !== '') { - return $value; - } - } - - return ''; - } } diff --git a/app/service/payment/order/PayOrderActionResolverService.php b/app/service/payment/order/PayOrderActionResolverService.php new file mode 100644 index 0000000..1a16f50 --- /dev/null +++ b/app/service/payment/order/PayOrderActionResolverService.php @@ -0,0 +1,254 @@ +> $rows 支付单列表行 + * @return array> 带操作项的列表行 + */ + public function resolveForRows(array $rows): array + { + foreach ($rows as $index => $row) { + $rows[$index] = $this->resolveForRow($row); + } + + return $rows; + } + + /** + * 补齐单行可操作项。 + * + * @param array $row 支付单行 + * @return array 带操作项的支付单行 + */ + public function resolveForRow(array $row): array + { + $actions = $this->buildActions($row); + + $row['actions'] = $actions; + $row['enabled_actions'] = array_values(array_map( + static fn (array $action): string => (string) $action['code'], + array_filter($actions, static fn (array $action): bool => (bool) $action['enabled']) + )); + $row['freeze_info'] = $this->riskControlService->freezeInfo($row); + $row['is_frozen'] = (bool) $row['freeze_info']['is_frozen']; + $row['refundable_amount'] = $this->refundableAmount($row); + $row['refundable_amount_text'] = $this->formatAmount((int) $row['refundable_amount']); + + return $row; + } + + /** + * 根据模型计算可操作项。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array 带操作项的轻量订单行 + */ + public function resolveForPayOrder(PayOrder $payOrder, ?BizOrder $bizOrder = null): array + { + return $this->resolveForRow([ + 'pay_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'status' => (int) $payOrder->status, + 'channel_id' => (int) $payOrder->channel_id, + 'plugin_code' => (string) ($payOrder->plugin_code ?? ''), + 'pay_amount' => (int) $payOrder->pay_amount, + 'biz_refund_amount' => (int) ($bizOrder?->refund_amount ?? 0), + 'notify_url' => (string) ($payOrder->notify_url ?? ''), + 'biz_notify_url' => (string) ($bizOrder?->notify_url ?? ''), + 'ext_json' => (array) ($payOrder->ext_json ?? []), + ]); + } + + /** + * 构建全部操作按钮。 + * + * @param array $row 支付单行 + * @return array> 操作项 + */ + private function buildActions(array $row): array + { + $frozen = $this->riskControlService->isFrozen($row); + $status = (int) ($row['status'] ?? -1); + $isSuccess = $status === TradeConstant::ORDER_STATUS_SUCCESS; + $hasNotifyUrl = trim((string) ($row['notify_url'] ?? '')) !== '' + || trim((string) ($row['biz_notify_url'] ?? '')) !== ''; + $pluginCode = trim((string) ($row['plugin_code'] ?? '')); + if ($pluginCode === '') { + $pluginCode = trim((string) ($row['channel_plugin_code'] ?? '')); + } + $hasChannel = (int) ($row['channel_id'] ?? 0) > 0 && $pluginCode !== ''; + $refundableAmount = $this->refundableAmount($row); + + return [ + $this->action( + PayOrderActionConstant::MANUAL_SUCCESS, + !$frozen && !$isSuccess && in_array($status, [ + TradeConstant::ORDER_STATUS_CREATED, + TradeConstant::ORDER_STATUS_PAYING, + TradeConstant::ORDER_STATUS_FAILED, + TradeConstant::ORDER_STATUS_CLOSED, + TradeConstant::ORDER_STATUS_TIMEOUT, + ], true), + $frozen ? '订单已冻结' : ($isSuccess ? '订单已成功,无需补单' : ''), + true, + true + ), + $this->action( + PayOrderActionConstant::RENOTIFY, + !$frozen && $isSuccess && $hasNotifyUrl, + $this->disabledReason($frozen, $isSuccess, $hasNotifyUrl, '只有成功订单可以重新通知', '订单未配置 notify_url'), + false, + false + ), + $this->action( + PayOrderActionConstant::ACTIVE_QUERY, + !$frozen && !$isSuccess && $hasChannel, + $frozen ? '订单已冻结' : ($isSuccess ? '订单已成功,无需查单' : ($hasChannel ? '' : '订单缺少通道或插件信息')), + false, + false + ), + $this->action( + PayOrderActionConstant::API_REFUND, + !$frozen && $isSuccess && $hasChannel && $refundableAmount > 0, + $this->refundDisabledReason($frozen, $isSuccess, $hasChannel, $refundableAmount), + true, + true + ), + $this->action( + PayOrderActionConstant::MANUAL_REFUND, + !$frozen && $isSuccess && $refundableAmount > 0, + $frozen ? '订单已冻结' : ($isSuccess ? ($refundableAmount > 0 ? '' : '订单暂无可退余额') : '只有成功订单可以退款'), + true, + true + ), + $this->action( + PayOrderActionConstant::FREEZE, + !$frozen, + $frozen ? '订单已冻结' : '', + true, + true + ), + $this->action( + PayOrderActionConstant::UNFREEZE, + $frozen, + $frozen ? '' : '订单未冻结', + false, + true + ), + ]; + } + + /** + * 构建单个操作项。 + * + * @param string $code 操作码 + * @param bool $enabled 是否可用 + * @param string $reason 禁用原因 + * @param bool $danger 是否危险操作 + * @param bool $confirm 是否需要确认 + * @return array 操作项 + */ + private function action(string $code, bool $enabled, string $reason = '', bool $danger = false, bool $confirm = false): array + { + return [ + 'code' => $code, + 'label' => PayOrderActionConstant::actionLabelMap()[$code] ?? $code, + 'enabled' => $enabled, + 'reason' => $enabled ? '' : $reason, + 'danger' => $danger, + 'confirm' => $confirm, + ]; + } + + /** + * 计算列表展示用可退余额。 + * + * 列表只用业务单累计退款金额做轻量估算;真正创建退款时会重新锁定支付单和 + * 退款单,按 CREATED/PROCESSING/SUCCESS 退款单重新计算可退余额。 + * + * @param array $row 支付单行 + * @return int 可退金额,单位分 + */ + private function refundableAmount(array $row): int + { + return max(0, (int) ($row['pay_amount'] ?? 0) - (int) ($row['biz_refund_amount'] ?? 0)); + } + + /** + * 生成通知禁用原因。 + * + * @param bool $frozen 是否冻结 + * @param bool $isSuccess 是否成功 + * @param bool $hasNotifyUrl 是否有通知地址 + * @param string $statusReason 状态原因 + * @param string $urlReason 地址原因 + * @return string 禁用原因 + */ + private function disabledReason(bool $frozen, bool $isSuccess, bool $hasNotifyUrl, string $statusReason, string $urlReason): string + { + if ($frozen) { + return '订单已冻结'; + } + + if (!$isSuccess) { + return $statusReason; + } + + return $hasNotifyUrl ? '' : $urlReason; + } + + /** + * 生成退款禁用原因。 + * + * @param bool $frozen 是否冻结 + * @param bool $isSuccess 是否成功 + * @param bool $hasChannel 是否有通道 + * @param int $refundableAmount 可退金额 + * @return string 禁用原因 + */ + private function refundDisabledReason(bool $frozen, bool $isSuccess, bool $hasChannel, int $refundableAmount): string + { + if ($frozen) { + return '订单已冻结'; + } + + if (!$isSuccess) { + return '只有成功订单可以退款'; + } + + if (!$hasChannel) { + return '订单缺少通道或插件信息'; + } + + return $refundableAmount > 0 ? '' : '订单暂无可退余额'; + } +} diff --git a/app/service/payment/order/PayOrderAdminActionService.php b/app/service/payment/order/PayOrderAdminActionService.php new file mode 100644 index 0000000..883d07e --- /dev/null +++ b/app/service/payment/order/PayOrderAdminActionService.php @@ -0,0 +1,371 @@ + 操作项结构 + */ + public function actions(string $payNo): array + { + [$payOrder, $bizOrder] = $this->loadPayAndBizOrder($payNo); + $row = $this->actionResolverService->resolveForPayOrder($payOrder, $bizOrder); + + return [ + 'pay_no' => (string) $payOrder->pay_no, + 'status' => (int) $payOrder->status, + 'actions' => $row['actions'], + 'enabled_actions' => $row['enabled_actions'], + 'freeze_info' => $row['freeze_info'], + 'is_frozen' => $row['is_frozen'], + 'refundable_amount' => $row['refundable_amount'], + 'refundable_amount_text' => $row['refundable_amount_text'], + ]; + } + + /** + * 重新创建并投递支付成功商户通知。 + * + * @param string $payNo 支付单号 + * @param array $input 操作参数 + * @param int $adminId 管理员ID + * @return array 操作结果 + */ + public function renotify(string $payNo, array $input = [], int $adminId = 0): array + { + [$payOrder, $bizOrder] = $this->loadPayAndBizOrder($payNo); + $this->riskControlService->assertNotFrozen($payOrder, '重新通知'); + + if ((int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) { + throw new BusinessStateException('只有成功订单可以重新通知', [ + 'pay_no' => $payNo, + 'status' => (int) $payOrder->status, + ]); + } + + $task = $this->merchantNotifyDispatcherService->enqueueManualPaySuccess( + $payOrder, + $bizOrder, + $adminId, + trim((string) ($input['reason'] ?? '')) + ); + if (!$task) { + throw new ValidationException('订单未配置 notify_url,无法重新通知'); + } + + return [ + 'pay_no' => (string) $payOrder->pay_no, + 'notify_no' => (string) $task->notify_no, + 'queued' => $this->paymentQueueService->sendMerchantNotify((string) $task->notify_no), + 'status' => (int) $task->status, + ]; + } + + /** + * 主动查询上游支付结果。 + * + * @param string $payNo 支付单号 + * @param array $input 操作参数 + * @param int $adminId 管理员ID + * @return array 查单结果 + */ + public function activeQuery(string $payNo, array $input = [], int $adminId = 0): array + { + [$payOrder] = $this->loadPayAndBizOrder($payNo); + $this->riskControlService->assertNotFrozen($payOrder, '主动查询'); + + if ((int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS) { + throw new BusinessStateException('订单已成功,无需主动查询', ['pay_no' => $payNo]); + } + + if ((int) $payOrder->channel_id <= 0) { + throw new BusinessStateException('订单缺少通道信息,无法主动查询', ['pay_no' => $payNo]); + } + + return $this->runtimeMaintenanceService->syncPayOrderByQuery($payNo, 'admin_manual_query'); + } + + /** + * 创建 API 退款单并投递上游退款队列。 + * + * @param string $payNo 支付单号 + * @param array $input 退款参数 + * @param int $adminId 管理员ID + * @return array 操作结果 + */ + public function apiRefund(string $payNo, array $input = [], int $adminId = 0): array + { + [$payOrder] = $this->loadPayAndBizOrder($payNo); + $this->riskControlService->assertNotFrozen($payOrder, 'API退款'); + + if ((int) $payOrder->channel_id <= 0) { + throw new BusinessStateException('订单缺少通道信息,无法发起 API 退款', ['pay_no' => $payNo]); + } + + $refundOrder = $this->createRefundFromAdmin($payNo, array_replace($input, [ + 'refund_full_remaining' => true, + 'reason' => trim((string) ($input['reason'] ?? '')) ?: '后台 API 全额退款', + ]), $adminId, 'api_refund'); + + return [ + 'pay_no' => $payNo, + 'refund_no' => (string) $refundOrder->refund_no, + 'queued' => $this->paymentQueueService->sendRefundDispatch((string) $refundOrder->refund_no), + 'status' => (int) $refundOrder->status, + ]; + } + + /** + * 创建并直接标记手动退款成功。 + * + * @param string $payNo 支付单号 + * @param array $input 退款参数 + * @param int $adminId 管理员ID + * @return array 操作结果 + */ + public function manualRefund(string $payNo, array $input = [], int $adminId = 0): array + { + [$payOrder] = $this->loadPayAndBizOrder($payNo); + $this->riskControlService->assertNotFrozen($payOrder, '手动退款'); + + $refundOrder = $this->createRefundFromAdmin($payNo, $input, $adminId, 'manual_refund'); + $refundOrder = $this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [ + 'succeeded_at' => $this->now(), + 'channel_refund_no' => '', + 'ext_json' => [ + 'manual_refund' => [ + 'admin_id' => $adminId, + 'reason' => trim((string) ($input['reason'] ?? '')), + 'operated_at' => $this->now(), + ], + ], + ]); + + return [ + 'pay_no' => $payNo, + 'refund_no' => (string) $refundOrder->refund_no, + 'status' => (int) $refundOrder->status, + ]; + } + + /** + * 手动补正支付单为成功。 + * + * @param string $payNo 支付单号 + * @param array $input 补单参数 + * @param int $adminId 管理员ID + * @return array 操作结果 + */ + public function manualSuccess(string $payNo, array $input = [], int $adminId = 0): array + { + [$payOrder] = $this->loadPayAndBizOrder($payNo); + $this->riskControlService->assertNotFrozen($payOrder, '手动补单'); + + if ((int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS) { + throw new BusinessStateException('订单已成功,无需手动补单', ['pay_no' => $payNo]); + } + + $reason = $this->requireReason($input, '补单原因不能为空'); + + $successInput = [ + 'source' => 'admin_manual_success', + ]; + + $payOrder = $this->payOrderLifecycleService->markPaySuccess($payNo, $successInput); + + return [ + 'pay_no' => (string) $payOrder->pay_no, + 'status' => (int) $payOrder->status, + 'paid_at' => $this->formatDateTime($payOrder->paid_at, ''), + ]; + } + + /** + * 冻结支付单。 + * + * @param string $payNo 支付单号 + * @param array $input 冻结参数 + * @param int $adminId 管理员ID + * @return array 操作结果 + */ + public function freeze(string $payNo, array $input = [], int $adminId = 0): array + { + $payOrder = $this->riskControlService->freeze($payNo, $input, $adminId); + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + + return $this->actionResolverService->resolveForPayOrder($payOrder, $bizOrder); + } + + /** + * 解冻支付单。 + * + * @param string $payNo 支付单号 + * @param array $input 解冻参数 + * @param int $adminId 管理员ID + * @return array 操作结果 + */ + public function unfreeze(string $payNo, array $input = [], int $adminId = 0): array + { + $payOrder = $this->riskControlService->unfreeze($payNo, $input, $adminId); + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + + return $this->actionResolverService->resolveForPayOrder($payOrder, $bizOrder); + } + + /** + * 创建后台退款单。 + * + * @param string $payNo 支付单号 + * @param array $input 退款参数 + * @param int $adminId 管理员ID + * @param string $type 操作类型 + * @return RefundOrder 退款单 + */ + private function createRefundFromAdmin(string $payNo, array $input, int $adminId, string $type): RefundOrder + { + $reason = $this->requireReason($input, '退款原因不能为空'); + $isFullRemainingRefund = (bool) ($input['refund_full_remaining'] ?? false); + $refundAmount = $isFullRemainingRefund ? 0 : $this->parseAmount($input); + $extJson = (array) ($input['ext_json'] ?? []); + $extJson['admin_action'] = [ + 'type' => $type, + 'admin_id' => $adminId, + 'reason' => $reason, + 'operated_at' => $this->now(), + ]; + + $refundInput = array_replace($input, [ + 'pay_no' => $payNo, + 'reason' => $reason, + 'ext_json' => $extJson, + ]); + if (!$isFullRemainingRefund) { + $refundInput['refund_amount'] = $refundAmount; + } + + return $this->refundService->createRefund($refundInput); + } + + /** + * 加载支付单及业务单。 + * + * @param string $payNo 支付单号 + * @return array{0: PayOrder, 1: BizOrder|null} 支付单和业务单 + */ + private function loadPayAndBizOrder(string $payNo): array + { + $payNo = trim($payNo); + if ($payNo === '') { + throw new ValidationException('pay_no 不能为空'); + } + + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + return [ + $payOrder, + $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no), + ]; + } + + /** + * 解析金额参数。 + * + * @param array $input 输入参数 + * @return int 金额,单位分 + */ + private function parseAmount(array $input): int + { + if (array_key_exists('refund_amount', $input) && (int) $input['refund_amount'] > 0) { + return (int) $input['refund_amount']; + } + + $money = trim((string) ($input['money'] ?? '')); + if ($money === '') { + throw new ValidationException('退款金额不能为空'); + } + + if (!preg_match('/^\d+(\.\d{1,2})?$/', $money)) { + throw new ValidationException('金额格式不正确'); + } + + [$yuan, $cent] = array_pad(explode('.', $money, 2), 2, '0'); + $amount = ((int) $yuan * 100) + (int) str_pad($cent, 2, '0'); + if ($amount <= 0) { + throw new ValidationException('退款金额不合法'); + } + + return $amount; + } + + /** + * 获取必填原因。 + * + * @param array $input 输入参数 + * @param string $message 错误提示 + * @return string 原因 + */ + private function requireReason(array $input, string $message): string + { + $reason = trim((string) ($input['reason'] ?? '')); + if ($reason === '') { + throw new ValidationException($message); + } + + return $reason; + } + +} diff --git a/app/service/payment/order/PayOrderAttemptService.php b/app/service/payment/order/PayOrderAttemptService.php index e26235c..87e4aaa 100644 --- a/app/service/payment/order/PayOrderAttemptService.php +++ b/app/service/payment/order/PayOrderAttemptService.php @@ -7,12 +7,14 @@ use app\common\constant\CommonConstant; use app\common\constant\NotifyConstant; use app\common\constant\RouteConstant; use app\common\constant\TradeConstant; +use app\common\util\FormatHelper; use app\exception\BusinessStateException; use app\exception\ConflictException; use app\exception\ValidationException; use app\model\merchant\Merchant; use app\model\payment\BizOrder; use app\model\payment\PayOrder; +use app\model\payment\PaymentChannel; use app\repository\payment\config\PaymentTypeRepository; use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; @@ -23,7 +25,7 @@ use app\service\payment\runtime\PaymentRouteService; /** * 支付单发起服务。 * - * 负责支付单预创建、通道路由选择、第三方装单和首轮状态落库。 + * 负责商户校验、选路、业务单复用、支付单创建和首轮插件拉起。 * * @property MerchantService $merchantService 商户服务 * @property PaymentRouteService $paymentRouteService 支付路由服务 @@ -31,7 +33,6 @@ use app\service\payment\runtime\PaymentRouteService; * @property BizOrderRepository $bizOrderRepository 业务订单仓库 * @property PayOrderRepository $payOrderRepository 支付单仓库 * @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库 - * @property PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器 * @property PayOrderChannelDispatchService $payOrderChannelDispatchService 支付单渠道派发服务 */ class PayOrderAttemptService extends BaseService @@ -45,7 +46,6 @@ class PayOrderAttemptService extends BaseService * @param BizOrderRepository $bizOrderRepository 业务订单仓库 * @param PayOrderRepository $payOrderRepository 支付订单仓库 * @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库 - * @param PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器 * @param PayOrderChannelDispatchService $payOrderChannelDispatchService 支付单渠道派发服务 */ public function __construct( @@ -55,16 +55,12 @@ class PayOrderAttemptService extends BaseService protected BizOrderRepository $bizOrderRepository, protected PayOrderRepository $payOrderRepository, protected PaymentTypeRepository $paymentTypeRepository, - protected PaymentOrderInputAssembler $orderInputAssembler, protected PayOrderChannelDispatchService $payOrderChannelDispatchService - ) { - } + ) {} /** * 预创建支付尝试。 * - * 该方法会完成商户、支付方式、路由、通道限额、串行尝试和自有通道手续费预占的完整预检查。 - * * @param array $input 支付预创建参数 * @return array 发起结果 * @throws ValidationException @@ -73,32 +69,125 @@ class PayOrderAttemptService extends BaseService */ public function preparePayAttempt(array $input): array { - $merchantId = (int) ($input['merchant_id'] ?? 0); - $merchantOrderNo = trim((string) ($input['merchant_order_no'] ?? '')); - $payTypeId = (int) ($input['pay_type_id'] ?? 0); - $payAmount = (int) ($input['pay_amount'] ?? 0); - - if ($merchantId <= 0 || $merchantOrderNo === '' || $payTypeId <= 0 || $payAmount <= 0) { - throw new ValidationException('支付入参不完整'); - } - + $merchantId = (int) $input['merchant_id']; + $payTypeId = (int) $input['pay_type_id']; + $payAmount = (int) $input['pay_amount']; + $this->assertPayAmountAllowed($payAmount); [$merchant, $merchantGroupId] = $this->resolveMerchantContext($merchantId); - /** @var PaymentType|null $paymentType */ - $paymentType = $this->paymentTypeRepository->find($payTypeId); - if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { - throw new BusinessStateException('支付方式不支持', ['pay_type_id' => $payTypeId]); + $this->ensurePaymentTypeEnabled($payTypeId); + $route = $this->paymentRouteService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $input); + /** @var PaymentChannel $channel */ + $channel = $route['selected_channel']['channel']; + + return $this->createAndDispatchPayAttempt( + $input, + $merchant, + $merchantGroupId, + $channel, + (int) $route['poll_group']->id, + $route + ); + } + + /** + * 预创建指定通道支付尝试。 + * + * 后台通道测试不参与商户路由选择,但仍走真实订单创建和插件拉起链路。 + * + * @param array $input 支付预创建参数 + * @param PaymentChannel $channel 指定支付通道 + * @return array 发起结果 + * @throws ValidationException + * @throws BusinessStateException + * @throws ConflictException + */ + public function preparePayAttemptByChannel(array $input, PaymentChannel $channel): array + { + $merchantId = (int) $input['merchant_id']; + $payTypeId = (int) $input['pay_type_id']; + $payAmount = (int) $input['pay_amount']; + $this->assertPayAmountAllowed($payAmount); + [$merchant, $merchantGroupId] = $this->resolveDirectMerchantContext($merchantId); + + if ((int) $channel->pay_type_id !== $payTypeId) { + throw new ValidationException('指定通道与支付方式不匹配', [ + 'channel_id' => (int) $channel->id, + 'channel_pay_type_id' => (int) $channel->pay_type_id, + 'pay_type_id' => $payTypeId, + ]); } - // 已选支付方式的直连支付才会进入正式选路。 - $route = $this->paymentRouteService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $input); - $selected = $route['selected_channel']; - /** @var PaymentChannel $channel */ - $channel = $selected['channel']; - $bizFields = $this->buildBizOrderFields($input); + $this->ensurePaymentTypeEnabled($payTypeId); - $payNo = $this->generateNo('PAY'); - $channelRequestNo = $this->generateNo('REQ'); + return $this->createAndDispatchPayAttempt($input, $merchant, $merchantGroupId, $channel, 0, [ + 'poll_group' => null, + 'candidates' => [], + 'selected_channel' => [ + 'channel' => $channel, + 'direct' => true, + ], + ]); + } + + /** + * 预创建收银台业务单。 + * + * 该方法只创建或复用业务单,不选路、不创建支付单。 + * + * @param array $input 收银台参数 + * @return array 发起结果 + * @throws ValidationException + * @throws BusinessStateException + * @throws ConflictException + */ + public function prepareCashierBizOrder(array $input): array + { + $merchantId = (int) $input['merchant_id']; + $merchantOrderNo = trim((string) $input['merchant_order_no']); + $payAmount = (int) $input['pay_amount']; + $this->assertPayAmountAllowed($payAmount); + [$merchant] = $this->resolveMerchantContext($merchantId); + $bizFields = $input; + $bizFields['ext_json'] = (array) ($bizFields['ext_json'] ?? []); + unset($bizFields['ext_json']['payment'], $bizFields['ext_json']['presentation']); + + $bizOrder = $this->transactionRetry(function () use ($merchantId, $merchantOrderNo, $payAmount, $bizFields) { + return $this->prepareCashierBizOrderInCurrentTransaction($merchantId, $merchantOrderNo, $payAmount, $bizFields); + }); + + return [ + 'merchant' => $merchant, + 'biz_order' => $bizOrder, + 'cashier_url' => $this->buildCashierPageUrl((string) $bizOrder->biz_no), + ]; + } + + /** + * 创建支付单并拉起支付插件。 + * + * @param array $input 支付预创建参数 + * @param Merchant $merchant 商户模型 + * @param int $merchantGroupId 商户分组ID快照 + * @param PaymentChannel $channel 已选支付通道 + * @param int $pollGroupId 轮询组ID快照,指定通道测试时为 0 + * @param array $route 路由上下文 + * @return array 发起结果 + */ + private function createAndDispatchPayAttempt( + array $input, + Merchant $merchant, + int $merchantGroupId, + PaymentChannel $channel, + int $pollGroupId, + array $route + ): array { + $merchantId = (int) $input['merchant_id']; + $merchantOrderNo = trim((string) $input['merchant_order_no']); + $payAmount = (int) $input['pay_amount']; + $bizFields = $input; + $bizFields['ext_json'] = (array) ($bizFields['ext_json'] ?? []); + unset($bizFields['ext_json']['payment'], $bizFields['ext_json']['presentation']); $prepared = $this->transactionRetry(function () use ( $input, @@ -106,161 +195,37 @@ class PayOrderAttemptService extends BaseService $merchantId, $merchantGroupId, $merchantOrderNo, - $payTypeId, $payAmount, - $route, $channel, - $payNo, - $channelRequestNo, + $pollGroupId, + $route, $bizFields ) { - // 在事务中完成业务单和支付单的原子创建,保证幂等与状态一致。 - $existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo); - $bizTraceNo = ''; - - if ($existingBizOrder) { - // 同一商户订单号只能复用原业务单,且金额必须完全一致。 - if ((int) $existingBizOrder->order_amount !== $payAmount) { - throw new ValidationException('同一商户订单号金额不一致', [ - 'merchant_id' => $merchantId, - 'merchant_order_no' => $merchantOrderNo, - ]); - } - - if (in_array((int) $existingBizOrder->status, [ - TradeConstant::ORDER_STATUS_SUCCESS, - TradeConstant::ORDER_STATUS_CLOSED, - TradeConstant::ORDER_STATUS_TIMEOUT, - ], true)) { - throw new BusinessStateException('支付单状态不允许重复创建', [ - 'biz_no' => (string) $existingBizOrder->biz_no, - 'status' => (int) $existingBizOrder->status, - ]); - } - - if (!empty($existingBizOrder->active_pay_no)) { - $activePayOrder = $this->payOrderRepository->findForUpdateByPayNo((string) $existingBizOrder->active_pay_no); - if ($activePayOrder && in_array((int) $activePayOrder->status, [TradeConstant::ORDER_STATUS_CREATED, TradeConstant::ORDER_STATUS_PAYING], true)) { - throw new ConflictException('重复请求', [ - 'biz_no' => (string) $existingBizOrder->biz_no, - 'active_pay_no' => (string) $existingBizOrder->active_pay_no, - ]); - } - } - - // 业务单一旦生成,订单展示字段与回调地址就不能在后续支付尝试里漂移。 - $this->assertBizOrderConsistency($existingBizOrder, $bizFields); - $bizOrder = $existingBizOrder; - $bizTraceNo = trim((string) ($bizOrder->trace_no ?? '')); - $dirty = false; - if ($bizTraceNo === '') { - // 旧单如果没有 trace_no,就补成业务单号,方便后续串起来查。 - $bizTraceNo = (string) $bizOrder->biz_no; - $bizOrder->trace_no = $bizTraceNo; - $dirty = true; - } - if ($dirty) { - $bizOrder->save(); - } - $attemptNo = (int) $bizOrder->attempt_count + 1; - } else { - $bizOrder = $this->bizOrderRepository->create([ - 'biz_no' => $this->generateNo('BIZ'), - 'trace_no' => $this->generateNo('TRC'), - 'merchant_id' => $merchantId, - 'merchant_order_no' => $merchantOrderNo, - 'subject' => $bizFields['subject'], - 'body' => $bizFields['body'], - 'notify_url' => $bizFields['notify_url'], - 'return_url' => $bizFields['return_url'], - 'client_ip' => $bizFields['client_ip'], - 'device' => $bizFields['device'], - 'order_amount' => $payAmount, - 'paid_amount' => 0, - 'refund_amount' => 0, - 'status' => TradeConstant::ORDER_STATUS_CREATED, - 'attempt_count' => 0, - 'ext_json' => $bizFields['ext_json'], - ]); - $bizTraceNo = (string) $bizOrder->trace_no; - $attemptNo = 1; - } - - // 支付单快照要以“当前请求 + 已确认业务单”为准,避免复用旧业务单时把上下文写空。 - $payOrderSeedExtJson = array_replace_recursive( - (array) ($bizOrder->ext_json ?? []), - (array) ($input['ext_json'] ?? []) + $expireAt = $this->resolvePayOrderExpireAt(); + $bizContext = $this->preparePayAttemptBizOrder( + $merchantId, + $merchantOrderNo, + $payAmount, + $bizFields, + $expireAt ); - $payOrderFields = $this->orderInputAssembler->buildOrderFields( + + /** @var BizOrder $bizOrder */ + $bizOrder = $bizContext['biz_order']; + $payNo = $this->generateNo('PAY'); + $payOrder = $this->createPayOrderForAttempt( $input, - null, $bizOrder, - $payOrderSeedExtJson + (string) $bizContext['trace_no'], + (int) $bizContext['attempt_no'], + $merchantGroupId, + $channel, + $pollGroupId, + $payNo, + $expireAt ); - $feeRateBp = (int) $channel->cost_rate_bp; - $splitRateBp = (int) $channel->split_rate_bp ?: 10000; - // 手续费和分账费率都按快照落库,后续配置变化不会影响这笔单的口径。 - $feeEstimated = $this->calculateAmountByBp($payAmount, $feeRateBp); - - if ((int) $channel->channel_mode === RouteConstant::CHANNEL_MODE_SELF && $feeEstimated > 0) { - // 自有通道先冻结预估手续费,避免后续余额不足。 - $this->merchantAccountService->freezeAmountInCurrentTransaction( - $merchantId, - $feeEstimated, - $payNo, - 'PAY_FREEZE:' . $payNo, - [ - 'merchant_order_no' => $merchantOrderNo, - 'pay_type_id' => $payTypeId, - 'channel_id' => (int) $channel->id, - 'remark' => '自有通道手续费预占', - ], - $bizTraceNo - ); - } - - $payOrder = $this->payOrderRepository->create([ - // 路由与通道快照只落在支付单里,业务单保持纯业务事实。 - 'pay_no' => $payNo, - 'biz_no' => (string) $bizOrder->biz_no, - 'trace_no' => $bizTraceNo, - 'merchant_id' => $merchantId, - 'merchant_group_id' => $merchantGroupId, - 'poll_group_id' => (int) $route['poll_group']->id, - 'attempt_no' => (int) $attemptNo, - 'channel_id' => (int) $channel->id, - 'pay_type_id' => $payTypeId, - 'plugin_code' => (string) $channel->plugin_code, - 'channel_type' => (int) $channel->channel_mode, - 'channel_mode' => (int) $channel->channel_mode, - 'pay_amount' => $payAmount, - 'notify_url' => (string) $payOrderFields['notify_url'], - 'return_url' => (string) $payOrderFields['return_url'], - 'client_ip' => (string) $payOrderFields['client_ip'], - 'device' => (string) $payOrderFields['device'], - 'fee_rate_bp_snapshot' => $feeRateBp, - 'split_rate_bp_snapshot' => $splitRateBp, - 'fee_estimated_amount' => $feeEstimated, - 'fee_actual_amount' => 0, - 'status' => TradeConstant::ORDER_STATUS_PAYING, - 'fee_status' => (int) $channel->channel_mode === RouteConstant::CHANNEL_MODE_SELF ? TradeConstant::FEE_STATUS_FROZEN : TradeConstant::FEE_STATUS_NONE, - 'settlement_status' => TradeConstant::SETTLEMENT_STATUS_NONE, - 'channel_request_no' => $channelRequestNo, - 'request_at' => $this->now(), - 'callback_status' => NotifyConstant::PROCESS_STATUS_PENDING, - 'callback_times' => 0, - 'ext_json' => (array) $payOrderFields['ext_json'], - ]); - - $bizOrder->active_pay_no = (string) $payOrder->pay_no; - $bizOrder->attempt_count = (int) $attemptNo; - $bizOrder->status = TradeConstant::ORDER_STATUS_PAYING; - if ($bizTraceNo !== '' && (string) ($bizOrder->trace_no ?? '') === '') { - // 把追踪号回写到业务单上,后续查单和对账能串到同一条链路。 - $bizOrder->trace_no = $bizTraceNo; - } - $bizOrder->save(); + $this->markBizOrderPaying($bizOrder, $payOrder, (int) $bizContext['attempt_no']); return [ 'merchant' => $merchant, @@ -274,12 +239,6 @@ class PayOrderAttemptService extends BaseService $payOrder = $prepared['pay_order']; /** @var BizOrder $bizOrder */ $bizOrder = $prepared['biz_order']; - /** @var \app\model\payment\PaymentChannel $channel */ - $channel = $prepared['route']['selected_channel']['channel']; - - // 支付单落库后立即拉起渠道订单,补全渠道返回的单号和参数快照。 - /** @var \app\model\merchant\Merchant $merchant */ - $merchant = $prepared['merchant']; $channelDispatchResult = $this->payOrderChannelDispatchService->dispatch($payOrder, $bizOrder, $channel, $merchant); $prepared['pay_order'] = $channelDispatchResult['pay_order']; @@ -289,110 +248,6 @@ class PayOrderAttemptService extends BaseService return $prepared; } - /** - * 预创建收银台业务单。 - * - * 该方法只负责业务单创建或复用,不创建支付单,供 `type` 为空的收银台入口使用。 - * - * @param array $input 收银台参数 - * @return array 发起结果 - * @throws ValidationException - * @throws BusinessStateException - * @throws ConflictException - */ - public function prepareCashierBizOrder(array $input): array - { - $merchantId = (int) ($input['merchant_id'] ?? 0); - $merchantOrderNo = trim((string) ($input['merchant_order_no'] ?? '')); - $payAmount = (int) ($input['pay_amount'] ?? 0); - - if ($merchantId <= 0 || $merchantOrderNo === '' || $payAmount <= 0) { - throw new ValidationException('支付入参不完整'); - } - - [$merchant, $merchantGroupId] = $this->resolveMerchantContext($merchantId); - $bizFields = $this->buildBizOrderFields($input); - - $prepared = $this->transactionRetry(function () use ( - $merchant, - $merchantId, - $merchantGroupId, - $merchantOrderNo, - $payAmount, - $bizFields - ) { - // 收银台预创建只关心业务单,不创建支付单,也不提前选通道。 - $existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo); - if ($existingBizOrder) { - if ((int) $existingBizOrder->order_amount !== $payAmount) { - throw new ValidationException('同一商户订单号金额不一致', [ - 'merchant_id' => $merchantId, - 'merchant_order_no' => $merchantOrderNo, - ]); - } - - if (in_array((int) $existingBizOrder->status, [ - TradeConstant::ORDER_STATUS_SUCCESS, - TradeConstant::ORDER_STATUS_CLOSED, - TradeConstant::ORDER_STATUS_TIMEOUT, - ], true)) { - throw new BusinessStateException('支付单状态不允许重复创建', [ - 'biz_no' => (string) $existingBizOrder->biz_no, - 'status' => (int) $existingBizOrder->status, - ]); - } - - // 收银台预创建重复请求时,必须沿用首单快照,不能把订单文案或回调地址改掉。 - $this->assertBizOrderConsistency($existingBizOrder, $bizFields); - $bizOrder = $existingBizOrder; - $dirty = false; - if ((string) ($bizOrder->trace_no ?? '') === '') { - // 老业务单如果没有追踪号,补成业务单号,方便后续串联查询。 - $bizOrder->trace_no = (string) $bizOrder->biz_no; - $dirty = true; - } - if ($dirty) { - $bizOrder->save(); - } - } else { - // 新收银台单直接作为业务锚点,支付单留到确认时再创建。 - $bizOrder = $this->bizOrderRepository->create([ - 'biz_no' => $this->generateNo('BIZ'), - 'trace_no' => $this->generateNo('TRC'), - 'merchant_id' => $merchantId, - 'merchant_order_no' => $merchantOrderNo, - 'subject' => $bizFields['subject'], - 'body' => $bizFields['body'], - 'notify_url' => $bizFields['notify_url'], - 'return_url' => $bizFields['return_url'], - 'client_ip' => $bizFields['client_ip'], - 'device' => $bizFields['device'], - 'order_amount' => $payAmount, - 'paid_amount' => 0, - 'refund_amount' => 0, - 'status' => TradeConstant::ORDER_STATUS_CREATED, - 'active_pay_no' => '', - 'attempt_count' => 0, - 'ext_json' => $bizFields['ext_json'], - ]); - } - - return [ - 'merchant' => $merchant, - 'biz_order' => $bizOrder->refresh(), - ]; - }); - - /** @var BizOrder $bizOrder */ - $bizOrder = $prepared['biz_order']; - - return [ - 'merchant' => $prepared['merchant'], - 'biz_order' => $bizOrder, - 'cashier_url' => $this->buildCashierPageUrl((string) $bizOrder->biz_no), - ]; - } - /** * 解析商户和商户分组。 * @@ -414,23 +269,440 @@ class PayOrderAttemptService extends BaseService } /** - * 归一化业务单字段。 + * 解析指定通道支付使用的商户上下文。 * - * @param array $input 统一入参 - * @return array 业务单字段 + * 指定通道测试不依赖商户分组路由,商户没有分组时也允许创建支付单。 + * + * @param int $merchantId 商户ID + * @return array{0: Merchant, 1: int} 商户和商户分组ID */ - private function buildBizOrderFields(array $input): array + private function resolveDirectMerchantContext(int $merchantId): array { - // 业务单只保存商户业务上下文;支付载体上下文留给 PayOrder,避免同一业务单多次尝试时互相污染。 - $fields = $this->orderInputAssembler->buildOrderFields( - $input, - null, - null, + $merchant = $this->merchantService->ensureMerchantPayEnabled($merchantId); + $merchantGroupId = (int) $merchant->group_id; + + if ($merchantGroupId > 0) { + $this->merchantService->ensureMerchantGroupEnabled($merchantGroupId); + } + + return [$merchant, $merchantGroupId]; + } + + /** + * 确认支付方式可用。 + * + * @param int $payTypeId 支付方式ID + * @return void + * @throws BusinessStateException + */ + private function ensurePaymentTypeEnabled(int $payTypeId): void + { + $paymentType = $this->paymentTypeRepository->find($payTypeId); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new BusinessStateException('支付方式不支持', ['pay_type_id' => $payTypeId]); + } + } + + /** + * 准备支付尝试使用的业务单。 + * + * @param int $merchantId 商户ID + * @param string $merchantOrderNo 商户订单号 + * @param int $payAmount 支付金额 + * @param array $bizFields 业务单字段 + * @param string|null $expireAt 过期时间 + * @return array{biz_order: BizOrder, attempt_no: int, trace_no: string} + */ + private function preparePayAttemptBizOrder( + int $merchantId, + string $merchantOrderNo, + int $payAmount, + array $bizFields, + ?string $expireAt + ): array { + $bizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo); + if ($bizOrder) { + $this->assertBizOrderReusable($bizOrder, $merchantId, $merchantOrderNo, $payAmount); + $this->assertNoActivePayAttempt($bizOrder); + $this->assertBizOrderConsistency($bizOrder, $bizFields); + $attemptNo = (int) $bizOrder->attempt_count + 1; + $this->assertPayAttemptAllowed($bizOrder, $attemptNo); + + return [ + 'biz_order' => $bizOrder, + 'attempt_no' => $attemptNo, + 'trace_no' => (string) $bizOrder->trace_no, + ]; + } + + $bizOrder = $this->createBizOrder($merchantId, $merchantOrderNo, $payAmount, $bizFields, $expireAt); + + return [ + 'biz_order' => $bizOrder, + 'attempt_no' => 1, + 'trace_no' => (string) $bizOrder->trace_no, + ]; + } + + /** + * 在事务内创建或复用收银台业务单。 + * + * @param int $merchantId 商户ID + * @param string $merchantOrderNo 商户订单号 + * @param int $payAmount 支付金额 + * @param array $bizFields 业务单字段 + * @return BizOrder 业务单 + */ + private function prepareCashierBizOrderInCurrentTransaction( + int $merchantId, + string $merchantOrderNo, + int $payAmount, + array $bizFields + ): BizOrder { + $bizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo); + if ($bizOrder) { + $this->assertBizOrderReusable($bizOrder, $merchantId, $merchantOrderNo, $payAmount); + $this->assertBizOrderConsistency($bizOrder, $bizFields); + + return $bizOrder->refresh(); + } + + return $this->createBizOrder( + $merchantId, + $merchantOrderNo, + $payAmount, + $bizFields, + $this->resolvePayOrderExpireAt() + )->refresh(); + } + + /** + * 创建业务单。 + * + * @param int $merchantId 商户ID + * @param string $merchantOrderNo 商户订单号 + * @param int $payAmount 支付金额 + * @param array $bizFields 业务单字段 + * @param string|null $expireAt 过期时间 + * @return BizOrder 业务单 + */ + private function createBizOrder( + int $merchantId, + string $merchantOrderNo, + int $payAmount, + array $bizFields, + ?string $expireAt + ): BizOrder { + return $this->bizOrderRepository->create([ + 'biz_no' => $this->generateNo('BIZ'), + 'trace_no' => $this->generateNo('TRC'), + 'merchant_id' => $merchantId, + 'merchant_order_no' => $merchantOrderNo, + 'subject' => $bizFields['subject'], + 'body' => $bizFields['body'], + 'notify_url' => $bizFields['notify_url'], + 'return_url' => $bizFields['return_url'], + 'client_ip' => $bizFields['client_ip'], + 'device' => $bizFields['device'], + 'order_amount' => $payAmount, + 'paid_amount' => 0, + 'refund_amount' => 0, + 'status' => TradeConstant::ORDER_STATUS_CREATED, + 'active_pay_no' => '', + 'attempt_count' => 0, + 'expire_at' => $expireAt, + 'ext_json' => $bizFields['ext_json'], + ]); + } + + /** + * 创建支付单。 + * + * @param array $input 支付预创建参数 + * @param BizOrder $bizOrder 业务单 + * @param string $traceNo 追踪号 + * @param int $attemptNo 尝试序号 + * @param int $merchantGroupId 商户分组ID + * @param PaymentChannel $channel 支付通道 + * @param int $pollGroupId 轮询组ID + * @param string $payNo 支付单号 + * @param string|null $expireAt 过期时间 + * @return PayOrder 支付单 + */ + private function createPayOrderForAttempt( + array $input, + BizOrder $bizOrder, + string $traceNo, + int $attemptNo, + int $merchantGroupId, + PaymentChannel $channel, + int $pollGroupId, + string $payNo, + ?string $expireAt + ): PayOrder { + $merchantId = (int) $input['merchant_id']; + $merchantOrderNo = trim((string) $input['merchant_order_no']); + $payTypeId = (int) $input['pay_type_id']; + $payAmount = (int) $input['pay_amount']; + $payOrderExtJson = array_replace_recursive( + (array) ($bizOrder->ext_json ?? []), (array) ($input['ext_json'] ?? []) ); - unset($fields['ext_json']['payment'], $fields['ext_json']['presentation'], $fields['ext_json']['plugin']); + $splitRateBp = (int) $channel->split_rate_bp; + $merchantShareAmount = $this->calculateAmountByBp($payAmount, $splitRateBp); + $serviceFeeAmount = max(0, $payAmount - $merchantShareAmount); - return $fields; + $this->freezeSelfChannelFee( + $channel, + $merchantId, + $merchantOrderNo, + $payTypeId, + $payNo, + $traceNo, + $serviceFeeAmount + ); + + return $this->payOrderRepository->create([ + 'pay_no' => $payNo, + 'biz_no' => (string) $bizOrder->biz_no, + 'trace_no' => $traceNo, + 'merchant_id' => $merchantId, + 'merchant_group_id' => $merchantGroupId, + 'poll_group_id' => $pollGroupId, + 'attempt_no' => $attemptNo, + 'channel_id' => (int) $channel->id, + 'pay_type_id' => $payTypeId, + 'plugin_code' => (string) $channel->plugin_code, + 'channel_type' => (int) $channel->channel_mode, + 'channel_mode' => (int) $channel->channel_mode, + 'pay_amount' => $payAmount, + 'notify_url' => (string) $input['notify_url'], + 'return_url' => (string) $input['return_url'], + 'client_ip' => (string) $input['client_ip'], + 'device' => (string) $input['device'], + 'split_rate_bp_snapshot' => $splitRateBp, + 'service_fee_amount' => $serviceFeeAmount, + 'status' => TradeConstant::ORDER_STATUS_PAYING, + 'service_fee_status' => (int) $channel->channel_mode === RouteConstant::CHANNEL_MODE_SELF && $serviceFeeAmount > 0 + ? TradeConstant::SERVICE_FEE_STATUS_FROZEN + : TradeConstant::SERVICE_FEE_STATUS_NONE, + 'settlement_status' => TradeConstant::SETTLEMENT_STATUS_NONE, + 'channel_request_no' => $this->generateNo('REQ'), + 'request_at' => $this->now(), + 'expire_at' => $expireAt, + 'callback_status' => NotifyConstant::PROCESS_STATUS_PENDING, + 'callback_times' => 0, + 'ext_json' => $payOrderExtJson, + ]); + } + + /** + * 激活业务单上的当前支付尝试。 + * + * @param BizOrder $bizOrder 业务单 + * @param PayOrder $payOrder 支付单 + * @param int $attemptNo 尝试序号 + * @return void + */ + private function markBizOrderPaying(BizOrder $bizOrder, PayOrder $payOrder, int $attemptNo): void + { + $bizOrder->active_pay_no = (string) $payOrder->pay_no; + $bizOrder->attempt_count = $attemptNo; + $bizOrder->status = TradeConstant::ORDER_STATUS_PAYING; + $bizOrder->save(); + } + + /** + * 自收通道预冻结平台服务费。 + * + * @param PaymentChannel $channel 支付通道 + * @param int $merchantId 商户ID + * @param string $merchantOrderNo 商户订单号 + * @param int $payTypeId 支付方式ID + * @param string $payNo 支付单号 + * @param string $traceNo 追踪号 + * @param int $serviceFeeAmount 平台服务费 + * @return void + */ + private function freezeSelfChannelFee( + PaymentChannel $channel, + int $merchantId, + string $merchantOrderNo, + int $payTypeId, + string $payNo, + string $traceNo, + int $serviceFeeAmount + ): void { + if ((int) $channel->channel_mode !== RouteConstant::CHANNEL_MODE_SELF || $serviceFeeAmount <= 0) { + return; + } + + $this->merchantAccountService->freezeAmountInCurrentTransaction( + $merchantId, + $serviceFeeAmount, + $payNo, + 'PAY_FREEZE:' . $payNo, + [ + 'merchant_order_no' => $merchantOrderNo, + 'pay_type_id' => $payTypeId, + 'channel_id' => (int) $channel->id, + 'remark' => '自收通道服务费预占', + ], + $traceNo + ); + } + + /** + * 校验业务单可以继续发起支付。 + * + * @param BizOrder $bizOrder 业务单 + * @param int $merchantId 商户ID + * @param string $merchantOrderNo 商户订单号 + * @param int $payAmount 支付金额 + * @return void + */ + private function assertBizOrderReusable(BizOrder $bizOrder, int $merchantId, string $merchantOrderNo, int $payAmount): void + { + if ((int) $bizOrder->order_amount !== $payAmount) { + throw new ValidationException('同一商户订单号金额不一致', [ + 'merchant_id' => $merchantId, + 'merchant_order_no' => $merchantOrderNo, + ]); + } + + if (in_array((int) $bizOrder->status, [ + TradeConstant::ORDER_STATUS_SUCCESS, + TradeConstant::ORDER_STATUS_CLOSED, + TradeConstant::ORDER_STATUS_TIMEOUT, + ], true)) { + throw new BusinessStateException('支付单状态不允许重复创建', [ + 'biz_no' => (string) $bizOrder->biz_no, + 'status' => (int) $bizOrder->status, + ]); + } + + if ((int) $bizOrder->status === TradeConstant::ORDER_STATUS_FAILED + && !$this->boolConfig('pay_order_failed_retry_enabled', true) + ) { + throw new BusinessStateException('支付失败后不允许重新发起支付', [ + 'biz_no' => (string) $bizOrder->biz_no, + ]); + } + } + + /** + * 校验同一业务单的支付尝试次数。 + * + * @param BizOrder $bizOrder 业务单 + * @param int $attemptNo 本次尝试序号 + * @return void + */ + private function assertPayAttemptAllowed(BizOrder $bizOrder, int $attemptNo): void + { + if (!$this->boolConfig('pay_order_attempt_limit_enabled', true)) { + return; + } + + $limit = max(1, (int) sys_config('pay_order_attempt_limit', 5)); + if ($attemptNo <= $limit) { + return; + } + + throw new BusinessStateException('支付尝试次数已达上限', [ + 'biz_no' => (string) $bizOrder->biz_no, + 'attempt_limit' => $limit, + ]); + } + + /** + * 校验全局支付金额边界。 + * + * @param int $payAmount 支付金额,单位分 + * @return void + */ + private function assertPayAmountAllowed(int $payAmount): void + { + if (!$this->boolConfig('pay_order_amount_limit_enabled', false)) { + return; + } + + $minAmount = $this->moneyConfigToCents('pay_order_min_amount_yuan', 1); + $maxAmount = $this->moneyConfigToCents('pay_order_max_amount_yuan', 0); + + if ($minAmount > 0 && $payAmount < $minAmount) { + throw new ValidationException('支付金额低于系统最小限制', [ + 'min_amount' => FormatHelper::amount($minAmount), + ]); + } + + if ($maxAmount > 0 && $payAmount > $maxAmount) { + throw new ValidationException('支付金额高于系统最大限制', [ + 'max_amount' => FormatHelper::amount($maxAmount), + ]); + } + } + + /** + * 读取布尔配置。 + * + * @param string $key 配置键 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function boolConfig(string $key, bool $default): bool + { + $value = strtolower(trim((string) sys_config($key, $default ? '1' : '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } + + /** + * 读取元金额配置并转换为分。 + * + * @param string $key 配置键 + * @param int $defaultCents 默认金额,单位分 + * @return int 金额,单位分 + */ + private function moneyConfigToCents(string $key, int $defaultCents): int + { + $money = trim((string) sys_config($key, FormatHelper::amount($defaultCents))); + if ($money === '') { + return $defaultCents; + } + + if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + return $defaultCents; + } + + [$integer, $fraction] = array_pad(explode('.', $money, 2), 2, ''); + $fraction = str_pad($fraction, 2, '0'); + + return ((int) $integer) * 100 + (int) substr($fraction, 0, 2); + } + + /** + * 防止同一业务单并发创建多个支付中订单。 + * + * @param BizOrder $bizOrder 业务单 + * @return void + * @throws ConflictException + */ + private function assertNoActivePayAttempt(BizOrder $bizOrder): void + { + if (empty($bizOrder->active_pay_no)) { + return; + } + + $activePayOrder = $this->payOrderRepository->findForUpdateByPayNo((string) $bizOrder->active_pay_no); + if ($activePayOrder && in_array((int) $activePayOrder->status, [ + TradeConstant::ORDER_STATUS_CREATED, + TradeConstant::ORDER_STATUS_PAYING, + ], true)) { + throw new ConflictException('重复请求', [ + 'biz_no' => (string) $bizOrder->biz_no, + 'active_pay_no' => (string) $bizOrder->active_pay_no, + ]); + } } /** @@ -446,7 +718,7 @@ class PayOrderAttemptService extends BaseService foreach (['subject', 'body', 'notify_url', 'return_url', 'client_ip', 'device'] as $field) { $current = trim((string) ($bizOrder->{$field} ?? '')); $incoming = trim((string) ($fields[$field] ?? '')); - if ($current !== '' && $incoming !== '' && $current !== $incoming) { + if ($current !== $incoming) { throw new ConflictException('商户订单信息不一致', [ 'biz_no' => (string) $bizOrder->biz_no, 'field' => $field, @@ -454,39 +726,15 @@ class PayOrderAttemptService extends BaseService } } - $currentExtJson = $this->stableBizExtJson((array) ($bizOrder->ext_json ?? [])); - $incomingExtJson = $this->stableBizExtJson((array) ($fields['ext_json'] ?? [])); - if (!empty($currentExtJson) && !empty($incomingExtJson) && $currentExtJson != $incomingExtJson) { + $currentExtJson = (array) ($bizOrder->ext_json ?? []); + $incomingExtJson = (array) ($fields['ext_json'] ?? []); + if ($currentExtJson != $incomingExtJson) { throw new ConflictException('商户订单扩展信息不一致', [ 'biz_no' => (string) $bizOrder->biz_no, ]); } } - /** - * 只比较业务单真正稳定的扩展字段。 - * - * `payment`、`presentation`、`plugin` 都属于支付尝试快照,不参与业务单幂等比较。 - * - * @param array $extJson 扩展字段 - * @return array - */ - private function stableBizExtJson(array $extJson): array - { - $stable = []; - foreach (['_protocol_version'] as $key) { - if (array_key_exists($key, $extJson)) { - $stable[$key] = $extJson[$key]; - } - } - - if (isset($extJson['merchant']) && is_array($extJson['merchant'])) { - $stable['merchant'] = $extJson['merchant']; - } - - return $stable; - } - /** * 构建收银台跳转地址。 * @@ -499,11 +747,33 @@ class PayOrderAttemptService extends BaseService } /** - * 计算手续费金额。 + * 根据后台配置解析支付单过期时间。 + * + * @return string|null 过期时间,关闭超时时返回 null + */ + private function resolvePayOrderExpireAt(): ?string + { + $enabled = in_array( + strtolower(trim((string) sys_config('pay_order_timeout_enabled', '1'))), + ['1', 'true', 'yes', 'on', 'enabled'], + true + ); + + if (!$enabled) { + return null; + } + + $minutes = max(1, (int) sys_config('pay_order_expire_minutes', 30)); + + return date('Y-m-d H:i:s', time() + $minutes * 60); + } + + /** + * 按基点计算金额。 * * @param int $amount 金额(分) * @param int $bp 费率基点,`10000` 表示 100% - * @return int 手续费金额(分) + * @return int 金额(分) */ private function calculateAmountByBp(int $amount, int $bp): int { @@ -511,7 +781,6 @@ class PayOrderAttemptService extends BaseService return 0; } - // 基点换算统一向下取整,避免手续费计算时出现超扣。 return (int) floor($amount * $bp / 10000); } } diff --git a/app/service/payment/order/PayOrderCallbackService.php b/app/service/payment/order/PayOrderCallbackService.php index 84a0f6e..56c5e2f 100644 --- a/app/service/payment/order/PayOrderCallbackService.php +++ b/app/service/payment/order/PayOrderCallbackService.php @@ -5,15 +5,18 @@ namespace app\service\payment\order; use app\common\base\BaseService; use app\common\constant\NotifyConstant; use app\common\constant\PaymentPluginStatusConstant; +use app\common\interface\ChannelNotifyInterface; +use app\common\interface\ChannelNotifyPayloadInterface; use app\exception\PaymentException; use app\exception\ResourceNotFoundException; -use app\exception\ValidationException; use app\model\payment\PayOrder; +use app\repository\payment\config\PaymentChannelRepository; use app\repository\payment\trade\PayOrderRepository; use app\service\payment\runtime\NotifyService; use app\service\payment\runtime\PaymentPluginManager; use support\Request; use support\Response; +use Throwable; /** * 支付单回调服务。 @@ -22,6 +25,7 @@ use support\Response; * * @property NotifyService $notifyService 通知服务 * @property PaymentPluginManager $paymentPluginManager 支付插件管理器 + * @property PaymentChannelRepository $paymentChannelRepository 支付通道仓库 * @property PayOrderRepository $payOrderRepository 支付单仓库 * @property PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务 */ @@ -32,51 +36,19 @@ class PayOrderCallbackService extends BaseService * * @param NotifyService $notifyService 通知服务 * @param PaymentPluginManager $paymentPluginManager 支付插件管理器 + * @param PaymentChannelRepository $paymentChannelRepository 支付通道仓库 * @param PayOrderRepository $payOrderRepository 支付单仓库 * @param PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务 */ public function __construct( protected NotifyService $notifyService, protected PaymentPluginManager $paymentPluginManager, + protected PaymentChannelRepository $paymentChannelRepository, protected PayOrderRepository $payOrderRepository, protected PayOrderLifecycleService $payOrderLifecycleService ) { } - /** - * 处理渠道回调载荷并推进支付状态。 - * - * @param array $input 回调载荷 - * @return PayOrder 支付订单模型 - * @throws ValidationException - */ - public function handleChannelCallback(array $input): PayOrder - { - $payNo = trim((string) ($input['pay_no'] ?? '')); - if ($payNo === '') { - throw new ValidationException('pay_no 不能为空', ['pay_no' => $payNo]); - } - - // 先落回调日志,后续无论成功还是失败,都可以从统一表里排查。 - $this->notifyService->recordPayCallback([ - 'pay_no' => $payNo, - 'channel_id' => (int) ($input['channel_id'] ?? 0), - 'callback_type' => (int) ($input['callback_type'] ?? NotifyConstant::CALLBACK_TYPE_ASYNC), - 'request_data' => $input['request_data'] ?? [], - 'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN), - 'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING), - 'process_result' => $input['process_result'] ?? [], - ]); - - $success = (bool) ($input['success'] ?? false); - // 回调链路只根据插件/渠道给出的结果收口支付单状态。 - if ($success) { - return $this->payOrderLifecycleService->markPaySuccess($payNo, $input); - } - - return $this->payOrderLifecycleService->markPayFailed($payNo, $input); - } - /** * 按支付单号处理真实第三方回调。 * @@ -89,172 +61,252 @@ class PayOrderCallbackService extends BaseService */ public function handlePluginCallback(string $payNo, Request $request): string|Response { - // 回调必须能定位到具体支付单,找不到就直接终止。 $payOrder = $this->payOrderRepository->findByPayNo($payNo); if (!$payOrder) { throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); } - $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); - + $plugin = null; + $callbackPayload = null; try { - // 插件必须直接返回标准结构,系统层只负责校验,不再兼容旧字段别名。 - $result = $this->validatePluginNotifyResult($plugin->notify($request)); - $status = (string) $result['status']; - - // 将插件返回值归一化为生命周期服务可消费的回调载荷。 - /** @var array $callbackPayload */ - $callbackPayload = [ - 'pay_no' => $payNo, - 'success' => $status === PaymentPluginStatusConstant::SUCCESS, - 'channel_id' => (int) $payOrder->channel_id, - 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, - 'request_data' => $request->all(), - 'verify_status' => NotifyConstant::VERIFY_STATUS_SUCCESS, - 'process_status' => $this->resolveProcessStatus($status), - 'process_result' => $result, - 'channel_trade_no' => (string) ($result['channel_trade_no'] ?? ''), - 'channel_order_no' => (string) ($result['channel_order_no'] ?? ''), - 'paid_at' => $result['paid_at'] ?? null, - 'failed_at' => $result['failed_at'] ?? null, - 'channel_error_code' => (string) ($result['channel_error_code'] ?? ''), - 'channel_error_msg' => (string) ($result['channel_error_msg'] ?? ''), - // 回调原文和插件解析结果只进入 ma_pay_callback_log; - // 支付单本身只更新状态、渠道单号和错误字段,避免 ext_json 变成通知历史桶。 - 'ext_json' => [], - ]; - // 部分渠道会返回实际手续费,补充进回调载荷,便于后续清算和对账。 - if ($result['fee_actual_amount'] !== null) { - $callbackPayload['fee_actual_amount'] = (int) $result['fee_actual_amount']; - } - if ($status === PaymentPluginStatusConstant::PENDING) { - // 渠道通知已通过验签但尚未终态时,只记录日志,不提前推进支付单状态。 - $this->notifyService->recordPayCallback($callbackPayload); - return $plugin->notifySuccess(); - } - - // 回调终态统一交给生命周期服务落库,避免状态推进分散在不同分支里。 - $this->handleChannelCallback($callbackPayload); - - // 只要验签通过且已被系统处理,统一回成功响应,避免渠道对失败终态反复重推。 - return $plugin->notifySuccess(); - } catch (PaymentException $e) { - // 验签失败或插件解析失败时,记录失败日志并返回失败响应,允许渠道按自身策略重推。 - $this->notifyService->recordPayCallback([ - 'pay_no' => $payNo, - 'channel_id' => (int) $payOrder->channel_id, - 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, - 'request_data' => array_merge($request->get(), $request->post()), - 'verify_status' => NotifyConstant::VERIFY_STATUS_FAILED, - 'process_status' => NotifyConstant::PROCESS_STATUS_FAILED, - 'process_result' => [ - 'message' => $e->getMessage(), - 'code' => $e->getCode(), - ], - ]); - - return $plugin->notifyFail(); - } catch (\Throwable $e) { - // 非业务异常同样记为失败,避免渠道重复推送造成状态抖动。 - $this->notifyService->recordPayCallback([ - 'pay_no' => $payNo, - 'channel_id' => (int) $payOrder->channel_id, - 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, - 'request_data' => array_merge($request->get(), $request->post()), - 'verify_status' => NotifyConstant::VERIFY_STATUS_FAILED, - 'process_status' => NotifyConstant::PROCESS_STATUS_FAILED, - 'process_result' => [ - 'message' => $e->getMessage(), - 'code' => 'PLUGIN_NOTIFY_ERROR', - ], - ]); - - return $plugin->notifyFail(); - } - } - - /** - * 校验插件回调结果。 - * - * 插件 `notify()` 必须直接返回当前系统约定的标准字段; - * 服务层不再做字段别名兼容或自动补齐。 - * - * @param array $result 插件返回值 - * @return array - * @throws PaymentException - */ - private function validatePluginNotifyResult(array $result): array - { - $requiredKeys = [ - 'status', - ]; - - foreach ($requiredKeys as $key) { - if (!array_key_exists($key, $result)) { - throw new PaymentException('插件回调返回缺少标准字段', 40200, [ - 'missing_key' => $key, + $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); + $notifyResult = PaymentPluginNotifyResultValidator::make($plugin->notify($request)) + ->withScene('notify_result') + ->withException(PaymentException::class) + ->validate(); + $notifyPayNo = trim((string) ($notifyResult['pay_no'] ?? '')); + if ($notifyPayNo !== '' && $notifyPayNo !== (string) $payOrder->pay_no) { + throw new PaymentException('插件回调定位的支付单与当前支付单不一致', 40200, [ + 'callback_pay_no' => (string) $payOrder->pay_no, + 'notify_pay_no' => $notifyPayNo, ]); } - } + $callbackPayload = $this->buildCallbackPayload($payOrder, $request->all(), $notifyResult); + $this->applyNotifyResult($payOrder, $notifyResult, $callbackPayload); - $status = strtolower(trim((string) $result['status'])); - if (!in_array($status, PaymentPluginStatusConstant::notifyStatuses(), true)) { - throw new PaymentException('插件回调返回的状态不合法', 40200, [ - 'status' => $status, - ]); - } + return $plugin->notifySuccess(); + } catch (PaymentException $e) { + $exception = (int) $e->getCode() === 400 + ? new PaymentException($e->getMessage(), 40200, $e->getData()) + : $e; + $this->recordCallbackFailure($payOrder, $request->all(), $exception, $callbackPayload); - $channelOrderNo = trim((string) ($result['channel_order_no'] ?? '')); - $channelTradeNo = trim((string) ($result['channel_trade_no'] ?? '')); - if ($channelOrderNo === '' && $channelTradeNo === '') { - throw new PaymentException('插件回调必须返回 channel_order_no 或 channel_trade_no', 40200); - } - if ($channelOrderNo === '') { - $channelOrderNo = $channelTradeNo; - } - if ($channelTradeNo === '') { - $channelTradeNo = $channelOrderNo; - } + return $plugin ? $plugin->notifyFail() : 'fail'; + } catch (Throwable $e) { + $this->recordCallbackFailure($payOrder, $request->all(), $e, $callbackPayload); - if (array_key_exists('ext_json', $result) && !is_array($result['ext_json'])) { - throw new PaymentException('插件回调 ext_json 必须为数组', 40200); + return $plugin ? $plugin->notifyFail() : 'fail'; } - - $feeActualAmount = null; - if (array_key_exists('fee_actual_amount', $result) && $result['fee_actual_amount'] !== null) { - if (!is_numeric($result['fee_actual_amount'])) { - throw new PaymentException('插件回调 fee_actual_amount 必须为数字', 40200); - } - $feeActualAmount = (int) $result['fee_actual_amount']; - } - - return [ - 'status' => $status, - 'message' => trim((string) ($result['message'] ?? '')), - 'channel_order_no' => $channelOrderNo, - 'channel_trade_no' => $channelTradeNo, - 'channel_status' => trim((string) ($result['channel_status'] ?? '')), - 'channel_error_code' => trim((string) ($result['channel_error_code'] ?? '')), - 'channel_error_msg' => trim((string) ($result['channel_error_msg'] ?? '')), - 'paid_at' => $result['paid_at'] ?? null, - 'failed_at' => $result['failed_at'] ?? null, - 'fee_actual_amount' => $feeActualAmount, - 'ext_json' => (array) ($result['ext_json'] ?? []), - ]; } /** - * 根据插件标准状态映射日志处理状态。 + * 按通道处理不携带支付单号的 HTTP 通知。 * - * @param string $status 标准状态 - * @return int + * 服务层只让插件定位 pay_no,定位成功后继续走标准插件 notify() 回调流程。 + * + * @param int $channelId 通道ID + * @param Request $request 请求对象 + * @return string|Response 字符串或响应对象 + * @throws ResourceNotFoundException */ - private function resolveProcessStatus(string $status): int + public function handleChannelNotify(int $channelId, Request $request): string|Response { - return match ($status) { - PaymentPluginStatusConstant::SUCCESS => NotifyConstant::PROCESS_STATUS_SUCCESS, - PaymentPluginStatusConstant::FAILED => NotifyConstant::PROCESS_STATUS_FAILED, - default => NotifyConstant::PROCESS_STATUS_PENDING, - }; + $channel = $this->paymentChannelRepository->find($channelId); + if (!$channel) { + throw new ResourceNotFoundException('支付通道不存在', ['channel_id' => $channelId]); + } + + $plugin = $this->paymentPluginManager->createByChannel($channel, (int) $channel->pay_type_id, true); + if (!$plugin instanceof ChannelNotifyInterface) { + throw new PaymentException('当前通道不支持通道通知入口', 40200, [ + 'channel_id' => $channelId, + 'plugin_code' => (string) $channel->plugin_code, + ]); + } + + try { + $result = $plugin->channelNotify($request); + $payNo = trim((string) ($result['pay_no'] ?? '')); + if ($payNo === '') { + throw new PaymentException('通道通知未定位到支付单', 40200, [ + 'channel_id' => $channelId, + 'result' => $result, + ]); + } + + return $this->handlePluginCallback($payNo, $request); + } catch (Throwable $e) { + return $plugin->notifyFail(); + } + } + + /** + * 按通道处理 Redis 队列投递的归一化流水载荷。 + * + * 该入口不依赖 Request。插件先通过数组载荷定位 pay_no,再通过 notifyPayload() + * 返回标准通知结果,服务层复用订单状态推进、回调日志和商户通知链路。 + * + * @param int $channelId 通道ID + * @param array $payload 已归一化的流水载荷 + * @return array 渠道回调日志载荷 + */ + public function handleChannelNotifyPayload(int $channelId, array $payload): array + { + $channel = $this->paymentChannelRepository->find($channelId); + if (!$channel) { + throw new ResourceNotFoundException('支付通道不存在', ['channel_id' => $channelId]); + } + + $plugin = $this->paymentPluginManager->createByChannel($channel, (int) $channel->pay_type_id, true); + if (!$plugin instanceof ChannelNotifyPayloadInterface) { + throw new PaymentException('当前通道不支持数组载荷通知入口', 40200, [ + 'channel_id' => $channelId, + 'plugin_code' => (string) $channel->plugin_code, + ]); + } + + $result = $plugin->channelNotifyPayload($payload); + $payNo = trim((string) ($result['pay_no'] ?? '')); + if ($payNo === '') { + throw new PaymentException('通道通知未定位到支付单', 40200, [ + 'channel_id' => $channelId, + 'result' => $result, + ]); + } + + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $callbackPayload = null; + try { + $notifyResult = PaymentPluginNotifyResultValidator::make($plugin->notifyPayload($payload)) + ->withScene('notify_result') + ->withException(PaymentException::class) + ->validate(); + $notifyPayNo = trim((string) ($notifyResult['pay_no'] ?? '')); + if ($notifyPayNo !== '' && $notifyPayNo !== (string) $payOrder->pay_no) { + throw new PaymentException('插件回调定位的支付单与当前支付单不一致', 40200, [ + 'callback_pay_no' => (string) $payOrder->pay_no, + 'notify_pay_no' => $notifyPayNo, + ]); + } + + $callbackPayload = $this->buildCallbackPayload($payOrder, $payload, $notifyResult); + $this->applyNotifyResult($payOrder, $notifyResult, $callbackPayload); + + return $callbackPayload; + } catch (PaymentException $e) { + $exception = (int) $e->getCode() === 400 + ? new PaymentException($e->getMessage(), 40200, $e->getData()) + : $e; + $this->recordCallbackFailure($payOrder, $payload, $exception, $callbackPayload); + throw $exception; + } catch (Throwable $e) { + $this->recordCallbackFailure($payOrder, $payload, $e, $callbackPayload); + throw $e; + } + } + + /** + * 构建生命周期服务可消费的回调载荷。 + * + * @param PayOrder $payOrder 支付单 + * @param array $requestData 请求或队列载荷 + * @param array $notifyResult 插件回调结果 + * @return array + */ + private function buildCallbackPayload(PayOrder $payOrder, array $requestData, array $notifyResult): array + { + $status = (string) $notifyResult['status']; + $payload = [ + 'pay_no' => (string) $payOrder->pay_no, + 'success' => $status === PaymentPluginStatusConstant::SUCCESS, + 'channel_id' => (int) $payOrder->channel_id, + 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, + 'request_data' => $requestData, + 'verify_status' => NotifyConstant::VERIFY_STATUS_SUCCESS, + 'process_status' => match ($status) { + PaymentPluginStatusConstant::SUCCESS => NotifyConstant::PROCESS_STATUS_SUCCESS, + PaymentPluginStatusConstant::FAILED => NotifyConstant::PROCESS_STATUS_FAILED, + default => NotifyConstant::PROCESS_STATUS_PENDING, + }, + 'process_result' => $notifyResult, + 'channel_order_no' => (string) $notifyResult['channel_order_no'], + 'channel_trade_no' => (string) $notifyResult['channel_trade_no'], + ]; + + foreach (['paid_at', 'failed_at', 'channel_error_code', 'channel_error_msg'] as $key) { + if (($notifyResult[$key] ?? null) !== null && $notifyResult[$key] !== '') { + $payload[$key] = $notifyResult[$key]; + } + } + + return $payload; + } + + /** + * 按插件通知结果推进支付单并记录回调日志。 + * + * @param PayOrder $payOrder 支付单 + * @param array $notifyResult 插件回调结果 + * @param array $callbackPayload 渠道回调日志载荷 + * @return void + */ + private function applyNotifyResult(PayOrder $payOrder, array $notifyResult, array $callbackPayload): void + { + $status = (string) $notifyResult['status']; + if ($status === PaymentPluginStatusConstant::PENDING) { + $this->notifyService->recordPayCallback($callbackPayload); + return; + } + + if ($status === PaymentPluginStatusConstant::SUCCESS) { + $this->payOrderLifecycleService->markPaySuccess((string) $payOrder->pay_no, $callbackPayload); + } else { + $this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, $callbackPayload); + } + + $this->notifyService->recordPayCallback($callbackPayload); + } + + /** + * 记录回调处理失败。 + * + * @param PayOrder $payOrder 支付单 + * @param array $requestData 请求或队列载荷 + * @param Throwable $e 异常 + * @param array|null $callbackPayload 已通过插件解析的回调载荷 + * @return void + */ + private function recordCallbackFailure(PayOrder $payOrder, array $requestData, Throwable $e, ?array $callbackPayload = null): void + { + $exceptionResult = [ + 'message' => $e->getMessage(), + 'code' => $e instanceof PaymentException ? $e->getCode() : 'PLUGIN_NOTIFY_ERROR', + ]; + + if ($callbackPayload !== null) { + $this->notifyService->recordPayCallback(array_replace($callbackPayload, [ + 'verify_status' => NotifyConstant::VERIFY_STATUS_SUCCESS, + 'process_status' => NotifyConstant::PROCESS_STATUS_FAILED, + 'process_result' => [ + 'notify_result' => $callbackPayload['process_result'] ?? [], + 'exception' => $exceptionResult, + ], + ])); + return; + } + + $this->notifyService->recordPayCallback([ + 'pay_no' => (string) $payOrder->pay_no, + 'channel_id' => (int) $payOrder->channel_id, + 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, + 'request_data' => $requestData, + 'verify_status' => NotifyConstant::VERIFY_STATUS_FAILED, + 'process_status' => NotifyConstant::PROCESS_STATUS_FAILED, + 'process_result' => $exceptionResult, + ]); } } diff --git a/app/service/payment/order/PayOrderChannelDispatchService.php b/app/service/payment/order/PayOrderChannelDispatchService.php index 1779672..0f0d864 100644 --- a/app/service/payment/order/PayOrderChannelDispatchService.php +++ b/app/service/payment/order/PayOrderChannelDispatchService.php @@ -9,10 +9,10 @@ use app\model\merchant\Merchant; use app\model\payment\BizOrder; use app\model\payment\PayOrder; use app\model\payment\PaymentChannel; -use app\model\payment\PaymentType; use app\repository\payment\config\PaymentTypeRepository; use app\repository\payment\trade\PayOrderRepository; use app\service\payment\runtime\PaymentPluginManager; +use support\Log; use Throwable; /** @@ -40,8 +40,7 @@ class PayOrderChannelDispatchService extends BaseService protected PaymentTypeRepository $paymentTypeRepository, protected PayOrderRepository $payOrderRepository, protected PayOrderLifecycleService $payOrderLifecycleService - ) { - } + ) {} /** * 拉起第三方支付单并回写渠道响应。 @@ -56,294 +55,163 @@ class PayOrderChannelDispatchService extends BaseService */ public function dispatch(PayOrder $payOrder, BizOrder $bizOrder, PaymentChannel $channel, Merchant $merchant): array { - try { - // 先构造支付插件实例,由插件完成具体渠道下单。 - $plugin = $this->paymentPluginManager->createByChannel($channel, (int) $payOrder->pay_type_id); - /** @var PaymentType|null $paymentType */ - $paymentType = $this->paymentTypeRepository->find((int) $payOrder->pay_type_id); - $extJson = (array) ($payOrder->ext_json ?? []); - $callbackUrl = rtrim(sys_config('site_url'), '/') . '/api/pay/' . $payOrder->pay_no . '/callback'; - - // 插件下单参数里同时带业务单号、支付单号和结构化扩展信息,方便渠道侧回调后能反查同一笔单。 - $channelResult = $this->validatePluginPayResult($plugin->pay([ - 'pay_no' => (string) $payOrder->pay_no, - 'order_id' => (string) $payOrder->pay_no, - 'biz_no' => (string) $payOrder->biz_no, - 'trace_no' => (string) $payOrder->trace_no, - 'channel_request_no' => (string) $payOrder->channel_request_no, - 'merchant_id' => (int) $payOrder->merchant_id, - 'merchant_no' => (string) $merchant->merchant_no, - 'pay_type_id' => (int) $payOrder->pay_type_id, - 'pay_type_code' => (string) ($paymentType->code ?? ''), - 'amount' => (int) $payOrder->pay_amount, - 'subject' => (string) ($bizOrder->subject ?? ''), - 'body' => (string) ($bizOrder->body ?? ''), - 'callback_url' => $callbackUrl, - 'notify_url' => (string) ($payOrder->notify_url ?? ''), - 'return_url' => (string) ($payOrder->return_url ?? ''), - 'client_ip' => (string) ($payOrder->client_ip ?? ''), - '_env' => (string) (($payOrder->device ?? '') ?: 'pc'), - 'extra' => $extJson, - ])); - - $payOrder = $this->transactionRetry(function () use ($payOrder, $channelResult) { - // 回写渠道订单号和支付参数快照,便于后续查询和回调排障。 - $latest = $this->payOrderRepository->findForUpdateByPayNo((string) $payOrder->pay_no); - if (!$latest) { - throw new ResourceNotFoundException('支付单不存在', ['pay_no' => (string) $payOrder->pay_no]); - } - - $latest->channel_order_no = (string) ($channelResult['chan_order_no'] ?? $latest->channel_order_no ?? ''); - $latest->channel_trade_no = (string) ($channelResult['chan_trade_no'] ?? $latest->channel_trade_no ?? ''); - $latest->ext_json = array_replace_recursive((array) $latest->ext_json, [ - 'presentation' => [ - 'params_type' => (string) $channelResult['pay_params']['type'], - 'product' => (string) ($channelResult['pay_product'] ?? ''), - 'action' => (string) ($channelResult['pay_action'] ?? ''), - 'params_snapshot' => $channelResult['pay_params'], - ], - 'plugin' => [ - 'pay_result' => (array) ($channelResult['ext_json'] ?? []), - ], - ]); - $latest->save(); - - return $latest->refresh(); - }); - } catch (PaymentException $e) { - // 插件层异常统一收口为支付失败,避免订单长时间停留在处理中。 - $this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [ - 'channel_error_msg' => $e->getMessage(), - 'channel_error_code' => (string) $e->getCode(), - 'ext_json' => [ - 'plugin' => [ - 'code' => (string) $payOrder->plugin_code, - ], - ], - ]); - - throw $e; - } catch (Throwable $e) { - // 非业务异常同样收口为失败态,并保留原始错误信息。 - $this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [ - 'channel_error_msg' => $e->getMessage(), - 'channel_error_code' => 'PLUGIN_CREATE_ORDER_ERROR', - 'ext_json' => [ - 'plugin' => [ - 'code' => (string) $payOrder->plugin_code, - ], - ], - ]); - - throw new PaymentException('创建第三方支付订单失败', 40215, [ - 'error' => $e->getMessage(), - 'plugin_code' => (string) $payOrder->plugin_code, - ]); - } + $pluginPayPayload = $this->buildPluginPayPayload($payOrder, $bizOrder, $merchant); + $pluginPayResult = $this->callPluginPay($payOrder, $channel, $pluginPayPayload); + $payOrder = $this->persistPluginPayResult($payOrder, $pluginPayResult); return [ 'pay_order' => $payOrder, - 'payment_result' => $channelResult, - 'pay_params' => $channelResult['pay_params'], + 'payment_result' => $pluginPayResult, + 'pay_params' => $pluginPayResult['pay_params'], ]; } /** - * 校验并归一化插件下单返回值。 + * 调用插件下单并校验返回结构。 * - * 插件返回值是支付页承接的唯一来源,必须在这里变成明确、可落库、可渲染的结构。 + * 插件创建、插件 pay() 和返回结构校验失败,都属于通道拉起失败,需要推进支付失败。 * - * @param array $result 插件下单返回值 - * @return array 标准下单返回值 - * @throws PaymentException - */ - private function validatePluginPayResult(array $result): array - { - foreach (['pay_product', 'pay_action', 'pay_params', 'chan_order_no'] as $key) { - if (!array_key_exists($key, $result)) { - throw new PaymentException('插件下单返回缺少标准字段', 40200, [ - 'missing_key' => $key, - ]); - } - } - - $payProduct = strtolower(trim((string) $result['pay_product'])); - $payAction = strtolower(trim((string) $result['pay_action'])); - $channelOrderNo = trim((string) $result['chan_order_no']); - $channelTradeNo = trim((string) ($result['chan_trade_no'] ?? '')); - - if ($payProduct === '') { - throw new PaymentException('插件下单返回 pay_product 不能为空', 40200); - } - if ($payAction === '') { - throw new PaymentException('插件下单返回 pay_action 不能为空', 40200); - } - if ($channelOrderNo === '') { - throw new PaymentException('插件下单返回 chan_order_no 不能为空', 40200); - } - if (array_key_exists('ext_json', $result) && !is_array($result['ext_json'])) { - throw new PaymentException('插件下单返回 ext_json 必须为数组', 40200); - } - - $payParams = $this->normalizePayParamsSnapshot($result['pay_params']); - $payParams = $this->validatePayParams($payParams); - - return [ - 'pay_product' => $payProduct, - 'pay_action' => $payAction, - 'pay_params' => $payParams, - 'chan_order_no' => $channelOrderNo, - 'chan_trade_no' => $channelTradeNo, - 'ext_json' => (array) ($result['ext_json'] ?? []), - ]; - } - - /** - * 校验支付页承接参数。 - * - * 每一种 `type` 都对应收银台的一种页面动作;必要载荷缺失时直接判定为插件异常。 - * - * @param array $payParams 支付参数 + * @param PayOrder $payOrder 支付单 + * @param PaymentChannel $channel 支付通道 + * @param array $payload 插件下单参数 * @return array * @throws PaymentException */ - private function validatePayParams(array $payParams): array + private function callPluginPay(PayOrder $payOrder, PaymentChannel $channel, array $payload): array { - $type = strtolower(trim((string) ($payParams['type'] ?? ''))); - if ($type === '') { - throw new PaymentException('插件下单返回 pay_params.type 不能为空', 40200); - } + try { + $plugin = $this->paymentPluginManager->createByChannel($channel, (int) $payOrder->pay_type_id); + $result = $plugin->pay($payload); - $aliases = [ - 'scan' => 'qrcode', - 'qr' => 'qrcode', - 'code' => 'qrcode', - 'redirect' => 'jump', - 'url' => 'jump', - 'wap' => 'h5', - 'form' => 'html', - 'app' => 'urlscheme', - 'applet' => 'mini', - 'wxplugin' => 'mini', - ]; - $type = $aliases[$type] ?? $type; - - $allowed = [ - 'jump', - 'web', - 'h5', - 'qrcode', - 'html', - 'jsapi', - 'urlscheme', - 'mini', - 'pos', - 'transfer', - 'json', - 'error', - ]; - if (!in_array($type, $allowed, true)) { - throw new PaymentException('插件下单返回 pay_params.type 不支持', 40200, [ - 'type' => $type, + return PaymentPluginPayResultValidator::make($result) + ->withScene('pay_result') + ->withException(PaymentException::class) + ->validate(); + } catch (PaymentException $e) { + // Validator 默认给 400,这里统一为支付通道错误码;插件主动抛出的业务码保持原样。 + $exception = (int) $e->getCode() === 400 ? new PaymentException($e->getMessage(), 40200, $e->getData()) : $e; + throw $this->recordPluginPayFailure($payOrder, $exception); + } catch (Throwable $e) { + Log::warning(sprintf( + '[PayOrderChannelDispatchService] 插件下单异常 pay_no=%s channel_id=%d exception=%s error=%s', + (string) $payOrder->pay_no, + (int) $channel->id, + get_class($e), + $e->getMessage() + )); + $exception = new PaymentException('创建第三方支付订单失败', 40200, [ + 'channel_error_code' => 'PLUGIN_CREATE_ORDER_ERROR', + 'exception_class' => get_class($e), ]); + throw $this->recordPluginPayFailure($payOrder, $exception); } - - $payParams['type'] = $type; - - if (in_array($type, ['jump', 'web', 'h5'], true)) { - $url = $this->firstText($payParams, ['redirect_url', 'payurl', 'pay_url', 'mweb_url', 'url']); - if ($url === '') { - throw new PaymentException('插件跳转支付缺少支付链接', 40200, [ - 'type' => $type, - ]); - } - $payParams['redirect_url'] = $url; - $payParams['payurl'] = $url; - } - - if ($type === 'qrcode') { - $qrcode = $this->firstText($payParams, ['qrcode_text', 'qrcode_data', 'qrcode_url', 'qrcode']); - if ($qrcode === '') { - throw new PaymentException('插件二维码支付缺少二维码内容', 40200); - } - $payParams['qrcode_text'] = $qrcode; - $payParams['qrcode'] = $qrcode; - } - - if ($type === 'html' && $this->firstText($payParams, ['html', 'action']) === '') { - throw new PaymentException('插件表单支付缺少 html 或 action', 40200); - } - - if ($type === 'urlscheme') { - $urlscheme = $this->firstText($payParams, ['urlscheme', 'redirect_url', 'order_str', 'order_string']); - if ($urlscheme === '') { - throw new PaymentException('插件 URL Scheme 支付缺少唤起参数', 40200); - } - $payParams['urlscheme'] = $urlscheme; - $payParams['redirect_url'] = $urlscheme; - } - - if ($type === 'jsapi' && $this->firstText($payParams, ['order_str', 'order_string', 'app_id', 'appId']) === '' && empty($payParams['jsapi_params'])) { - throw new PaymentException('插件 JSAPI 支付缺少拉起参数', 40200); - } - - if ($type === 'mini' && $this->firstText($payParams, ['path', 'scheme', 'urlscheme', 'trade_no']) === '' && empty($payParams['mini_params'])) { - throw new PaymentException('插件小程序支付缺少跳转参数', 40200); - } - - if ($type === 'error' && $this->firstText($payParams, ['message', 'msg', 'error']) === '') { - throw new PaymentException('插件错误支付结果缺少错误信息', 40200); - } - - return $payParams; } /** - * 归一化支付参数快照,便于后续页面渲染和排障。 + * 构建插件下单参数。 * - * @param array|object|null $payParams 支付参数数组或对象 - * @return array 参数快照 + * @param PayOrder $payOrder 支付单 + * @param BizOrder $bizOrder 业务单 + * @param Merchant $merchant 商户 + * @return array */ - private function normalizePayParamsSnapshot(mixed $payParams): array + private function buildPluginPayPayload(PayOrder $payOrder, BizOrder $bizOrder, Merchant $merchant): array { - if (is_array($payParams)) { - return $payParams; - } + $paymentType = $this->paymentTypeRepository->find((int) $payOrder->pay_type_id); - if (is_object($payParams) && method_exists($payParams, 'toArray')) { - // 有些插件会返回对象,这里统一转成数组,方便后续落库和页面回显。 - $data = $payParams->toArray(); - return is_array($data) ? $data : []; - } - - return []; + return [ + 'pay_no' => (string) $payOrder->pay_no, + 'order_id' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'trace_no' => (string) $payOrder->trace_no, + 'channel_request_no' => (string) $payOrder->channel_request_no, + 'merchant_id' => (int) $payOrder->merchant_id, + 'merchant_no' => (string) $merchant->merchant_no, + 'pay_type_id' => (int) $payOrder->pay_type_id, + 'pay_type_code' => (string) ($paymentType->code ?? ''), + 'amount' => (int) $payOrder->pay_amount, + 'subject' => (string) ($bizOrder->subject ?? ''), + 'body' => (string) ($bizOrder->body ?? ''), + 'callback_url' => rtrim(sys_config('site_url'), '/') . '/api/pay/' . $payOrder->pay_no . '/callback', + 'notify_url' => (string) ($payOrder->notify_url ?? ''), + 'return_url' => (string) ($payOrder->return_url ?? ''), + 'client_ip' => (string) ($payOrder->client_ip ?? ''), + '_env' => (string) (($payOrder->device ?? '') ?: 'pc'), + 'extra' => (array) ($payOrder->ext_json ?? []), + ]; } /** - * 从候选字段中取首个非空文本。 + * 回写渠道成功返回的支付承接参数。 * - * @param array $data 数据 - * @param array $keys 候选字段 - * @return string + * @param PayOrder $payOrder 支付单 + * @param array $pluginPayResult 插件支付结果 + * @return PayOrder 支付单 */ - private function firstText(array $data, array $keys): string + private function persistPluginPayResult(PayOrder $payOrder, array $pluginPayResult): PayOrder { - foreach ($keys as $key) { - $value = $data[$key] ?? null; - if ($value === null) { - continue; + return $this->transactionRetry(function () use ($payOrder, $pluginPayResult) { + // 回写渠道订单号和支付承接页结果,便于后续查询和页面渲染。 + $latest = $this->payOrderRepository->findForUpdateByPayNo((string) $payOrder->pay_no); + if (!$latest) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => (string) $payOrder->pay_no]); } - if (is_scalar($value)) { - $text = trim((string) $value); - if ($text !== '') { - return $text; - } - } - } + $latest->channel_order_no = (string) ($pluginPayResult['chan_order_no'] ?? $latest->channel_order_no ?? ''); + $latest->channel_trade_no = (string) ($pluginPayResult['chan_trade_no'] ?? $latest->channel_trade_no ?? ''); + $extJson = (array) $latest->ext_json; + $extJson['presentation'] = [ + 'pay_page' => $pluginPayResult['pay_page'], + 'pay_type' => $pluginPayResult['pay_type'], + 'pay_product' => $pluginPayResult['pay_product'], + 'pay_action' => $pluginPayResult['pay_action'], + 'pay_params' => $pluginPayResult['pay_params'], + ]; + $latest->ext_json = $extJson; + $latest->save(); - return ''; + return $latest->refresh(); + }); + } + + /** + * 记录插件下单失败,并返回带支付单号的异常。 + * + * @param PayOrder $payOrder 支付单 + * @param PaymentException $e 支付异常 + * @return PaymentException 可继续抛给入口层的异常 + */ + private function recordPluginPayFailure(PayOrder $payOrder, PaymentException $e): PaymentException + { + $data = $e->getData(); + $message = trim(preg_replace('/\s+/', ' ', $e->getMessage()) ?? ''); + $message = $message !== '' ? $message : '支付通道返回异常'; + $code = (string) ($data['channel_error_code'] ?? ($e->getCode() ?: 'PLUGIN_PAY_FAILED')); + + $this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [ + 'channel_error_msg' => $message, + 'channel_error_code' => $code, + 'ext_json' => [ + 'presentation' => [ + 'pay_page' => 'error', + 'pay_type' => '', + 'pay_product' => 'error', + 'pay_action' => 'error', + 'pay_params' => [ + 'error_msg' => $message, + 'code' => $code, + 'pay_no' => (string) $payOrder->pay_no, + ], + ], + ], + ]); + + $data['pay_no'] = (string) $payOrder->pay_no; + + return new PaymentException( + $message, + (int) ($e->getCode() ?: 40200), + $data + ); } } - - diff --git a/app/service/payment/order/PayOrderFeeService.php b/app/service/payment/order/PayOrderFeeService.php index edcc223..e56a948 100644 --- a/app/service/payment/order/PayOrderFeeService.php +++ b/app/service/payment/order/PayOrderFeeService.php @@ -9,9 +9,9 @@ use app\model\payment\PayOrder; use app\service\account\funds\MerchantAccountService; /** - * 支付单手续费处理服务。 + * 支付单平台服务费处理服务。 * - * 负责支付成功时的手续费结算,以及终态时的冻结手续费释放。 + * 负责支付成功时的平台服务费结算,以及终态时的冻结服务费释放。 * * @property MerchantAccountService $merchantAccountService 商户账户服务 */ @@ -29,47 +29,34 @@ class PayOrderFeeService extends BaseService } /** - * 处理支付成功后的手续费结算。 + * 处理支付成功后的平台服务费结算。 * * @param PayOrder $payOrder 支付订单 - * @param int $actualFee actual手续费 + * @param int $serviceFee 平台服务费 * @param string $payNo 支付单号 * @param string $traceNo 追踪号 * @return void */ - public function settleSuccessFee(PayOrder $payOrder, int $actualFee, string $payNo, string $traceNo): void + public function settleSuccessFee(PayOrder $payOrder, int $serviceFee, string $payNo, string $traceNo): void { if ((int) $payOrder->channel_type !== RouteConstant::CHANNEL_MODE_SELF) { return; } - $estimated = (int) $payOrder->fee_estimated_amount; - if ($actualFee > $estimated) { - // 实际手续费高于预估值时,先扣掉预冻结部分,再把差额从可用余额里补扣。 - if ($estimated > 0) { - $this->merchantAccountService->deductFrozenAmountInCurrentTransaction( - (int) $payOrder->merchant_id, - $estimated, - $payNo, - 'PAY_DEDUCT:' . $payNo, - [ - 'pay_no' => $payNo, - 'remark' => '自有通道手续费扣减', - ], - $traceNo - ); - } + if ((int) $payOrder->service_fee_status === TradeConstant::SERVICE_FEE_STATUS_DEDUCTED) { + return; + } - $diff = $actualFee - $estimated; - if ($diff > 0) { - $this->merchantAccountService->debitAvailableAmountInCurrentTransaction( + if ((int) $payOrder->service_fee_status === TradeConstant::SERVICE_FEE_STATUS_RELEASED) { + if ($serviceFee > 0) { + $this->merchantAccountService->debitPayFeeAmountInCurrentTransaction( (int) $payOrder->merchant_id, - $diff, + $serviceFee, $payNo, - 'PAY_DEDUCT_DIFF:' . $payNo, + 'PAY_LATE_DEDUCT:' . $payNo, [ 'pay_no' => $payNo, - 'remark' => '自有通道手续费差额扣减', + 'remark' => '自收通道终态后成功服务费补扣', ], $traceNo ); @@ -77,57 +64,25 @@ class PayOrderFeeService extends BaseService return; } - if ($actualFee < $estimated) { - // 实际手续费低于预估值时,先按实际值扣减冻结金额,再把多冻结部分释放回可用余额。 - if ($actualFee > 0) { - $this->merchantAccountService->deductFrozenAmountInCurrentTransaction( - (int) $payOrder->merchant_id, - $actualFee, - $payNo, - 'PAY_DEDUCT:' . $payNo, - [ - 'pay_no' => $payNo, - 'remark' => '自有通道手续费扣减', - ], - $traceNo - ); - } - - $diff = $estimated - $actualFee; - if ($diff > 0) { - $this->merchantAccountService->releaseFrozenAmountInCurrentTransaction( - (int) $payOrder->merchant_id, - $diff, - $payNo, - 'PAY_RELEASE:' . $payNo, - [ - 'pay_no' => $payNo, - 'remark' => '自有通道手续费释放差额', - ], - $traceNo - ); - } + if ($serviceFee <= 0) { return; } - if ($actualFee > 0) { - // 实际值和预估值一致时,直接把冻结金额一次性扣减掉即可。 - $this->merchantAccountService->deductFrozenAmountInCurrentTransaction( - (int) $payOrder->merchant_id, - $actualFee, - $payNo, - 'PAY_DEDUCT:' . $payNo, - [ - 'pay_no' => $payNo, - 'remark' => '自有通道手续费扣减', - ], - $traceNo - ); - } + $this->merchantAccountService->deductFrozenAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + $serviceFee, + $payNo, + 'PAY_DEDUCT:' . $payNo, + [ + 'pay_no' => $payNo, + 'remark' => '自收通道服务费扣减', + ], + $traceNo + ); } /** - * 释放支付单已冻结的手续费。 + * 释放支付单已冻结的平台服务费。 * * @param PayOrder $payOrder 支付订单 * @param string $payNo 支付单号 @@ -141,14 +96,19 @@ class PayOrderFeeService extends BaseService return; } - // 只有真正处于冻结态的手续费才需要释放,已经扣减或已释放的单子直接跳过。 - if ((int) $payOrder->fee_status !== TradeConstant::FEE_STATUS_FROZEN) { + // 只有真正处于冻结态的服务费才需要释放,已经扣减或已释放的单子直接跳过。 + if ((int) $payOrder->service_fee_status !== TradeConstant::SERVICE_FEE_STATUS_FROZEN) { + return; + } + + $serviceFee = (int) $payOrder->service_fee_amount; + if ($serviceFee <= 0) { return; } $this->merchantAccountService->releaseFrozenAmountInCurrentTransaction( (int) $payOrder->merchant_id, - (int) $payOrder->fee_estimated_amount, + $serviceFee, $payNo, 'PAY_RELEASE:' . $payNo, [ @@ -159,7 +119,3 @@ class PayOrderFeeService extends BaseService ); } } - - - - diff --git a/app/service/payment/order/PayOrderLifecycleService.php b/app/service/payment/order/PayOrderLifecycleService.php index 4fcffe1..71652b8 100644 --- a/app/service/payment/order/PayOrderLifecycleService.php +++ b/app/service/payment/order/PayOrderLifecycleService.php @@ -12,14 +12,15 @@ use app\exception\ResourceNotFoundException; use app\model\payment\PayOrder; use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; +use support\Log; use Webman\Event\Event; /** * 支付单生命周期服务。 * - * 负责支付单状态推进、关闭、超时和手续费处理。 + * 负责支付单状态推进、关闭、超时和平台服务费处理。 * - * @property PayOrderFeeService $payOrderFeeService 支付单手续费服务 + * @property PayOrderFeeService $payOrderFeeService 支付单平台服务费服务 * @property BizOrderRepository $bizOrderRepository 业务订单仓库 * @property PayOrderRepository $payOrderRepository 支付单仓库 */ @@ -28,7 +29,7 @@ class PayOrderLifecycleService extends BaseService /** * 构造方法。 * - * @param PayOrderFeeService $payOrderFeeService 支付单手续费服务 + * @param PayOrderFeeService $payOrderFeeService 支付单平台服务费服务 * @param BizOrderRepository $bizOrderRepository 业务订单仓库 * @param PayOrderRepository $payOrderRepository 支付订单仓库 */ @@ -42,7 +43,7 @@ class PayOrderLifecycleService extends BaseService /** * 标记支付成功。 * - * 用于支付回调或主动查单成功后的状态推进;自有通道在这里完成手续费正式扣减。 + * 用于支付回调或主动查单成功后的状态推进;自收通道在这里完成平台服务费正式扣减。 * * @param string $payNo 支付单号 * @param array $input 状态数据 @@ -87,7 +88,7 @@ class PayOrderLifecycleService extends BaseService } if (TradeConstant::isOrderTerminalStatus($currentStatus)) { - return $payOrder; + return $this->markTerminalPaySuccessInCurrentTransaction($payOrder, $input, $currentStatus, $shouldNotifyMerchant); } if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) { @@ -97,22 +98,19 @@ class PayOrderLifecycleService extends BaseService ]); } - // 成功态优先使用插件回传的实际手续费,没有则沿用预估值。 - $actualFee = array_key_exists('fee_actual_amount', $input) - ? (int) $input['fee_actual_amount'] - : (int) $payOrder->fee_estimated_amount; + // 平台服务费按下单时的分账快照确定,第三方回调费用只作为上游成本,不参与商户扣费。 + $serviceFee = (int) $payOrder->service_fee_amount; $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); - // 成功后正式结算手续费,避免自有通道只冻结不扣减。 - $this->payOrderFeeService->settleSuccessFee($payOrder, $actualFee, $payNo, $traceNo); + // 成功后正式结算平台服务费,避免自收通道只冻结不扣减。 + $this->payOrderFeeService->settleSuccessFee($payOrder, $serviceFee, $payNo, $traceNo); $payOrder->status = TradeConstant::ORDER_STATUS_SUCCESS; $payOrder->paid_at = $input['paid_at'] ?? $this->now(); - $payOrder->fee_actual_amount = $actualFee; - // 平台代收和自有通道的手续费、结算状态规则不同,这里统一收口。 - $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF - ? TradeConstant::FEE_STATUS_DEDUCTED - : TradeConstant::FEE_STATUS_NONE; + // 平台代收和自收通道的服务费、清算状态规则不同,这里统一收口。 + $payOrder->service_fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF && $serviceFee > 0 + ? TradeConstant::SERVICE_FEE_STATUS_DEDUCTED + : TradeConstant::SERVICE_FEE_STATUS_NONE; $payOrder->settlement_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT ? TradeConstant::SETTLEMENT_STATUS_PENDING : TradeConstant::SETTLEMENT_STATUS_NONE; @@ -122,12 +120,69 @@ class PayOrderLifecycleService extends BaseService $payOrder->channel_error_code = ''; $payOrder->channel_error_msg = ''; $payOrder->callback_times = (int) $payOrder->callback_times + 1; - $payOrder->ext_json = array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []); - $payOrder->save(); + $payOrder->ext_json = $this->keepSupportedExtJson( + array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []) + ); // 业务单状态也要一起收口,保证支付单和业务单一致。 - $this->syncBizOrderAfterSuccess($payOrder, $traceNo); - $shouldNotifyMerchant = true; + $shouldNotifyMerchant = $this->syncBizOrderAfterSuccess($payOrder, $traceNo); + if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT && !$shouldNotifyMerchant) { + $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE; + } + $payOrder->save(); + + return $payOrder->refresh(); + } + + /** + * 已进入失败、关闭或超时后的可信成功回调补偿。 + * + * 这类状态不能静默忽略:如果业务单尚未被其它支付单支付成功,就补正为成功; + * 如果业务单已经成功,则标记为重复晚到成功,留给后台人工处理或后续自动退款。 + * + * @param PayOrder $payOrder 已加锁支付单 + * @param array $input 状态数据 + * @param int $previousStatus 原终态 + * @param bool $shouldNotifyMerchant 是否需要通知商户 + * @return PayOrder 支付订单模型 + */ + private function markTerminalPaySuccessInCurrentTransaction(PayOrder $payOrder, array $input, int $previousStatus, bool &$shouldNotifyMerchant = false): PayOrder + { + $payNo = (string) $payOrder->pay_no; + $serviceFee = (int) $payOrder->service_fee_amount; + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + + $this->payOrderFeeService->settleSuccessFee($payOrder, $serviceFee, $payNo, $traceNo); + + $payOrder->status = TradeConstant::ORDER_STATUS_SUCCESS; + $payOrder->paid_at = $input['paid_at'] ?? $this->now(); + $payOrder->service_fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF && $serviceFee > 0 + ? TradeConstant::SERVICE_FEE_STATUS_DEDUCTED + : TradeConstant::SERVICE_FEE_STATUS_NONE; + $payOrder->callback_status = NotifyConstant::PROCESS_STATUS_SUCCESS; + $payOrder->channel_trade_no = (string) ($input['channel_trade_no'] ?? $payOrder->channel_trade_no ?? ''); + $payOrder->channel_order_no = (string) ($input['channel_order_no'] ?? $payOrder->channel_order_no ?? ''); + $payOrder->channel_error_code = ''; + $payOrder->channel_error_msg = ''; + $payOrder->callback_times = (int) $payOrder->callback_times + 1; + $payOrder->ext_json = $this->keepSupportedExtJson( + array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []) + ); + + $shouldNotifyMerchant = $this->syncBizOrderAfterSuccess($payOrder, $traceNo); + $payOrder->settlement_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT && $shouldNotifyMerchant + ? TradeConstant::SETTLEMENT_STATUS_PENDING + : TradeConstant::SETTLEMENT_STATUS_NONE; + $payOrder->save(); + + if (!$shouldNotifyMerchant) { + Log::warning(sprintf( + '[PayOrderLifecycle] 终态支付单收到晚到成功,业务单已支付 pay_no=%s biz_no=%s previous_status=%s', + $payNo, + (string) $payOrder->biz_no, + $previousStatus + )); + } return $payOrder->refresh(); } @@ -187,22 +242,30 @@ class PayOrderLifecycleService extends BaseService } $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); - // 失败时只释放需要冻结的手续费,避免重复扣减或重复释放。 - $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付失败释放手续费'); + // 失败时只释放需要冻结的服务费,避免重复扣减或重复释放。 + $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付失败释放服务费'); + + $rawChannelErrorCode = (string) ($input['channel_error_code'] ?? $payOrder->channel_error_code ?? ''); + $rawChannelErrorMsg = (string) ($input['channel_error_msg'] ?? $payOrder->channel_error_msg ?? '支付失败'); + $channelErrorCode = $this->clipColumnValue($rawChannelErrorCode, 64); + $channelErrorMsg = $this->clipColumnValue($rawChannelErrorMsg, 255); + $extJson = $this->keepSupportedExtJson( + array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []) + ); $payOrder->status = TradeConstant::ORDER_STATUS_FAILED; - $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF - ? TradeConstant::FEE_STATUS_RELEASED - : TradeConstant::FEE_STATUS_NONE; + $payOrder->service_fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF && (int) $payOrder->service_fee_amount > 0 + ? TradeConstant::SERVICE_FEE_STATUS_RELEASED + : TradeConstant::SERVICE_FEE_STATUS_NONE; $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE; $payOrder->callback_status = NotifyConstant::PROCESS_STATUS_FAILED; $payOrder->channel_trade_no = (string) ($input['channel_trade_no'] ?? $payOrder->channel_trade_no ?? ''); $payOrder->channel_order_no = (string) ($input['channel_order_no'] ?? $payOrder->channel_order_no ?? ''); - $payOrder->channel_error_code = (string) ($input['channel_error_code'] ?? $payOrder->channel_error_code ?? ''); - $payOrder->channel_error_msg = (string) ($input['channel_error_msg'] ?? $payOrder->channel_error_msg ?? '支付失败'); + $payOrder->channel_error_code = $channelErrorCode; + $payOrder->channel_error_msg = $channelErrorMsg; $payOrder->failed_at = $input['failed_at'] ?? $this->now(); $payOrder->callback_times = (int) $payOrder->callback_times + 1; - $payOrder->ext_json = array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []); + $payOrder->ext_json = $extJson; $payOrder->save(); // 支付单进入终态后,同步回业务单,避免上游只能依赖支付单判断结果。 @@ -267,23 +330,18 @@ class PayOrderLifecycleService extends BaseService } $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); - // 关闭单据时同样要处理冻结手续费,防止资金一直占用。 - $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付关闭释放手续费'); + // 关闭单据时同样要处理冻结服务费,防止资金一直占用。 + $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付关闭释放服务费'); $payOrder->status = TradeConstant::ORDER_STATUS_CLOSED; - $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF - ? TradeConstant::FEE_STATUS_RELEASED - : TradeConstant::FEE_STATUS_NONE; + $payOrder->service_fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF && (int) $payOrder->service_fee_amount > 0 + ? TradeConstant::SERVICE_FEE_STATUS_RELEASED + : TradeConstant::SERVICE_FEE_STATUS_NONE; $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE; $payOrder->closed_at = $input['closed_at'] ?? $this->now(); - $extJson = (array) $payOrder->ext_json; - $reason = trim((string) ($input['reason'] ?? '')); - if ($reason !== '') { - $extJson['lifecycle'] = array_replace((array) ($extJson['lifecycle'] ?? []), [ - 'close_reason' => $reason, - ]); - } - $payOrder->ext_json = array_replace_recursive($extJson, $input['ext_json'] ?? []); + $payOrder->ext_json = $this->keepSupportedExtJson( + array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []) + ); $payOrder->save(); // 关闭态也要同步给业务单,避免后续继续拉起支付。 @@ -348,23 +406,18 @@ class PayOrderLifecycleService extends BaseService } $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); - // 超时单同样释放冻结手续费,确保后续可以重新发起支付。 - $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付超时释放手续费'); + // 超时单同样释放冻结服务费,确保后续可以重新发起支付。 + $this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付超时释放服务费'); $payOrder->status = TradeConstant::ORDER_STATUS_TIMEOUT; - $payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF - ? TradeConstant::FEE_STATUS_RELEASED - : TradeConstant::FEE_STATUS_NONE; + $payOrder->service_fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF && (int) $payOrder->service_fee_amount > 0 + ? TradeConstant::SERVICE_FEE_STATUS_RELEASED + : TradeConstant::SERVICE_FEE_STATUS_NONE; $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE; $payOrder->timeout_at = $input['timeout_at'] ?? $this->now(); - $extJson = (array) $payOrder->ext_json; - $reason = trim((string) ($input['reason'] ?? '')); - if ($reason !== '') { - $extJson['lifecycle'] = array_replace((array) ($extJson['lifecycle'] ?? []), [ - 'timeout_reason' => $reason, - ]); - } - $payOrder->ext_json = array_replace_recursive($extJson, $input['ext_json'] ?? []); + $payOrder->ext_json = $this->keepSupportedExtJson( + array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []) + ); $payOrder->save(); $this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_TIMEOUT, 'timeout_at'); @@ -378,23 +431,33 @@ class PayOrderLifecycleService extends BaseService * * @param PayOrder $payOrder 支付订单 * @param string $traceNo 追踪号 - * @return void + * @return bool 是否需要继续发送正常商户成功通知 */ - private function syncBizOrderAfterSuccess(PayOrder $payOrder, string $traceNo): void + private function syncBizOrderAfterSuccess(PayOrder $payOrder, string $traceNo): bool { $bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $payOrder->biz_no); if (!$bizOrder) { - return; + return true; + } + + $orderAmount = (int) $bizOrder->order_amount; + $paidAmount = (int) $bizOrder->paid_amount; + if ((int) $bizOrder->status === TradeConstant::ORDER_STATUS_SUCCESS && $orderAmount > 0 && $paidAmount >= $orderAmount) { + return false; } $bizOrder->status = TradeConstant::ORDER_STATUS_SUCCESS; - $bizOrder->paid_amount = (int) $bizOrder->paid_amount + (int) $payOrder->pay_amount; + $bizOrder->paid_amount = $orderAmount > 0 + ? min($orderAmount, $paidAmount + (int) $payOrder->pay_amount) + : $paidAmount + (int) $payOrder->pay_amount; $bizOrder->active_pay_no = null; $bizOrder->paid_at = $payOrder->paid_at; if (empty($bizOrder->trace_no)) { $bizOrder->trace_no = $traceNo; } $bizOrder->save(); + + return true; } /** @@ -440,4 +503,51 @@ class PayOrderLifecycleService extends BaseService ]); } + /** + * 按数据库短字段长度裁剪文本。 + * + * @param string $value 原始文本 + * @param int $maxLength 最大字符长度 + * @return string 裁剪后的文本 + */ + private function clipColumnValue(string $value, int $maxLength): string + { + $value = trim($value); + if ($value === '' || $maxLength <= 0) { + return ''; + } + + if (function_exists('mb_strlen') && function_exists('mb_substr')) { + return mb_strlen($value, 'UTF-8') > $maxLength + ? mb_substr($value, 0, $maxLength, 'UTF-8') + : $value; + } + + return strlen($value) > $maxLength ? substr($value, 0, $maxLength) : $value; + } + + /** + * 支付单扩展字段只保留协议、商户透传、支付载体和承接页快照。 + * + * @param array $extJson 原始扩展字段 + * @return array 已过滤扩展字段 + */ + private function keepSupportedExtJson(array $extJson): array + { + $supported = []; + foreach (['_protocol_version', '_submit_type'] as $key) { + if (array_key_exists($key, $extJson) && $extJson[$key] !== null && $extJson[$key] !== '') { + $supported[$key] = $extJson[$key]; + } + } + + foreach (['merchant', 'payment', 'presentation', 'personal_receipt'] as $key) { + if (isset($extJson[$key]) && is_array($extJson[$key]) && $extJson[$key] !== []) { + $supported[$key] = $extJson[$key]; + } + } + + return $supported; + } + } diff --git a/app/service/payment/order/PayOrderQueryService.php b/app/service/payment/order/PayOrderQueryService.php index deda637..b1dd3f9 100644 --- a/app/service/payment/order/PayOrderQueryService.php +++ b/app/service/payment/order/PayOrderQueryService.php @@ -26,6 +26,7 @@ use app\repository\payment\trade\PayOrderRepository; * @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库 * @property NotifyTaskRepository $notifyTaskRepository 通知任务仓库 * @property PayOrderReportService $payOrderReportService 支付单报表服务 + * @property PayOrderActionResolverService $payOrderActionResolverService 支付单操作项计算服务 */ class PayOrderQueryService extends BaseService { @@ -38,6 +39,7 @@ class PayOrderQueryService extends BaseService * @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库 * @param NotifyTaskRepository $notifyTaskRepository 通知任务仓库 * @param PayOrderReportService $payOrderReportService 支付单报表服务 + * @param PayOrderActionResolverService $payOrderActionResolverService 支付单操作项计算服务 * @return void */ public function __construct( @@ -46,7 +48,8 @@ class PayOrderQueryService extends BaseService protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository, protected PaymentTypeRepository $paymentTypeRepository, protected NotifyTaskRepository $notifyTaskRepository, - protected PayOrderReportService $payOrderReportService + protected PayOrderReportService $payOrderReportService, + protected PayOrderActionResolverService $payOrderActionResolverService ) { } @@ -60,11 +63,12 @@ class PayOrderQueryService extends BaseService * @param int $page 页码 * @param int $pageSize 每页条数 * @param int|null $merchantId 商户ID + * @param bool $includeActions 是否返回后台可操作项 * @return array{list: array>, total: int, page: int, size: int, pay_types: array} 支付订单列表结构 */ - public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array + public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null, bool $includeActions = false): array { - $query = $this->buildPayOrderQuery($merchantId); + $query = $this->buildPayOrderQuery($merchantId, $includeActions); $keyword = trim((string) ($filters['keyword'] ?? '')); if ($keyword !== '') { @@ -113,7 +117,10 @@ class PayOrderQueryService extends BaseService $list = []; foreach ($paginator->items() as $item) { - $list[] = $this->payOrderReportService->formatPayOrderRow($this->rowToArray($item)); + $list[] = $this->payOrderReportService->formatPayOrderRow($item->toArray()); + } + if ($includeActions) { + $list = $this->payOrderActionResolverService->resolveForRows($list); } return [ @@ -132,11 +139,12 @@ class PayOrderQueryService extends BaseService * * @param string $payNo 支付单号 * @param int|null $merchantId 商户ID + * @param bool $includeActions 是否返回后台可操作项 * @return array{pay_order: PayOrder, biz_order: \app\model\payment\BizOrder|null, pay_order_view: array|null, timeline: array>, account_ledgers: iterable, account_ledgers_view: array>, notify_tasks: array>} 支付详情结构 * @throws ValidationException * @throws ResourceNotFoundException */ - public function detail(string $payNo, ?int $merchantId = null): array + public function detail(string $payNo, ?int $merchantId = null, bool $includeActions = false): array { $payNo = trim($payNo); if ($payNo === '') { @@ -154,20 +162,26 @@ class PayOrderQueryService extends BaseService } $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); - $detailRow = $this->buildPayOrderQuery($merchantId) + $detailRow = $this->buildPayOrderQuery($merchantId, $includeActions) ->where('po.pay_no', $payNo) ->first(); $timeline = $this->payOrderReportService->buildPayTimeline($payOrder); $accountLedgers = $this->loadPayLedgers($payOrder); $accountLedgerRows = []; foreach ($accountLedgers as $ledger) { - $accountLedgerRows[] = $this->payOrderReportService->formatLedgerRow($this->rowToArray($ledger)); + $accountLedgerRows[] = $this->payOrderReportService->formatLedgerRow($ledger->toArray()); + } + $payOrderView = $detailRow ? $this->payOrderReportService->formatPayOrderRow($detailRow->toArray()) : null; + if ($includeActions && $payOrderView) { + $payOrderView = $this->payOrderActionResolverService->resolveForRow($payOrderView); } return [ 'pay_order' => $payOrder, 'biz_order' => $bizOrder, - 'pay_order_view' => $detailRow ? $this->payOrderReportService->formatPayOrderRow($this->rowToArray($detailRow)) : null, + 'pay_order_view' => $payOrderView, + 'actions' => $includeActions ? ($payOrderView['actions'] ?? []) : [], + 'enabled_actions' => $includeActions ? ($payOrderView['enabled_actions'] ?? []) : [], 'timeline' => $timeline, 'account_ledgers' => $accountLedgers, 'account_ledgers_view' => $accountLedgerRows, @@ -202,9 +216,10 @@ class PayOrderQueryService extends BaseService * 查询支付单详情展示行,供列表与详情复用。 * * @param int|null $merchantId 商户ID + * @param bool $includeActionColumns 是否包含后台动作计算所需字段 * @return \Illuminate\Database\Eloquent\Builder */ - private function buildPayOrderQuery(?int $merchantId = null) + private function buildPayOrderQuery(?int $merchantId = null, bool $includeActionColumns = false) { $query = $this->payOrderRepository->query() ->from('ma_pay_order as po') @@ -228,12 +243,10 @@ class PayOrderQueryService extends BaseService 'po.channel_type', 'po.channel_mode', 'po.pay_amount', - 'po.fee_rate_bp_snapshot', 'po.split_rate_bp_snapshot', - 'po.fee_estimated_amount', - 'po.fee_actual_amount', + 'po.service_fee_amount', 'po.status', - 'po.fee_status', + 'po.service_fee_status', 'po.settlement_status', 'po.channel_request_no', 'po.channel_order_no', @@ -275,7 +288,27 @@ class PayOrderQueryService extends BaseService 't.code as pay_type_code', 't.name as pay_type_name', 't.icon as pay_type_icon', + ]) + ->selectRaw("COALESCE((SELECT ff.freeze_no FROM ma_merchant_fund_freeze ff WHERE ff.pay_no = po.pay_no AND ff.status = 1 AND ff.remaining_amount > 0 ORDER BY ff.id DESC LIMIT 1), '') AS freeze_no") + ->selectRaw("COALESCE((SELECT ff.freeze_type FROM ma_merchant_fund_freeze ff WHERE ff.pay_no = po.pay_no AND ff.status = 1 AND ff.remaining_amount > 0 ORDER BY ff.id DESC LIMIT 1), 0) AS freeze_type") + ->selectRaw("COALESCE((SELECT ff.remaining_amount FROM ma_merchant_fund_freeze ff WHERE ff.pay_no = po.pay_no AND ff.status = 1 AND ff.remaining_amount > 0 ORDER BY ff.id DESC LIMIT 1), 0) AS freeze_remaining_amount") + ->selectRaw("COALESCE((SELECT ff.reason FROM ma_merchant_fund_freeze ff WHERE ff.pay_no = po.pay_no AND ff.status = 1 AND ff.remaining_amount > 0 ORDER BY ff.id DESC LIMIT 1), '') AS freeze_reason") + ->selectRaw("COALESCE((SELECT ff.admin_id FROM ma_merchant_fund_freeze ff WHERE ff.pay_no = po.pay_no AND ff.status = 1 AND ff.remaining_amount > 0 ORDER BY ff.id DESC LIMIT 1), 0) AS freeze_admin_id") + ->selectRaw("(SELECT ff.available_at FROM ma_merchant_fund_freeze ff WHERE ff.pay_no = po.pay_no AND ff.status = 1 AND ff.remaining_amount > 0 ORDER BY ff.id DESC LIMIT 1) AS freeze_available_at") + ->selectRaw("(SELECT ff.frozen_at FROM ma_merchant_fund_freeze ff WHERE ff.pay_no = po.pay_no AND ff.status = 1 AND ff.remaining_amount > 0 ORDER BY ff.id DESC LIMIT 1) AS frozen_at") + ->selectRaw("'' AS unfreeze_reason") + ->selectRaw("0 AS unfrozen_by") + ->selectRaw("NULL AS unfrozen_at"); + + if ($includeActionColumns) { + // 通知地址只用于后台按钮判断,避免影响商户/API 侧原有列表输出面。 + $query->addSelect([ + 'po.notify_url', + 'po.return_url', + 'bo.notify_url as biz_notify_url', + 'bo.return_url as biz_return_url', ]); + } if ($merchantId !== null && $merchantId > 0) { $query->where('po.merchant_id', $merchantId); @@ -337,27 +370,4 @@ class PayOrderQueryService extends BaseService ->all(); } - /** - * 将查询结果统一转换为纯数组,避免直接强转模型对象时把内部属性泄漏出去。 - * - * @param mixed $row 查询结果行 - * @return array - */ - private function rowToArray(mixed $row): array - { - if (is_array($row)) { - return $row; - } - - if (is_object($row) && method_exists($row, 'toArray')) { - /** @var array $data */ - $data = $row->toArray(); - return $data; - } - - /** @var array $data */ - $data = (array) $row; - return $data; - } - } diff --git a/app/service/payment/order/PayOrderReportService.php b/app/service/payment/order/PayOrderReportService.php index aaae68d..977f1bf 100644 --- a/app/service/payment/order/PayOrderReportService.php +++ b/app/service/payment/order/PayOrderReportService.php @@ -34,15 +34,14 @@ class PayOrderReportService extends BaseService $row['biz_status_text'] = $this->textFromMap((int) ($row['biz_status'] ?? -1), TradeConstant::orderStatusMap()); $row['status_text'] = $this->textFromMap((int) ($row['status'] ?? -1), TradeConstant::orderStatusMap()); - $row['fee_status_text'] = $this->textFromMap((int) ($row['fee_status'] ?? -1), TradeConstant::feeStatusMap()); + $row['service_fee_status_text'] = $this->textFromMap((int) ($row['service_fee_status'] ?? -1), TradeConstant::serviceFeeStatusMap()); $row['settlement_status_text'] = $this->textFromMap((int) ($row['settlement_status'] ?? -1), TradeConstant::settlementStatusMap()); $row['callback_status_text'] = $this->textFromMap((int) ($row['callback_status'] ?? -1), NotifyConstant::processStatusMap()); $row['channel_type_text'] = $this->textFromMap((int) ($row['channel_type'] ?? -1), RouteConstant::channelTypeMap()); $row['channel_mode_text'] = $this->textFromMap((int) ($row['channel_mode'] ?? -1), RouteConstant::channelModeMap()); $row['pay_amount_text'] = $this->formatAmount((int) ($row['pay_amount'] ?? 0)); - $row['fee_estimated_amount_text'] = $this->formatAmount((int) ($row['fee_estimated_amount'] ?? 0)); - $row['fee_actual_amount_text'] = $this->formatAmount((int) ($row['fee_actual_amount'] ?? 0)); + $row['service_fee_amount_text'] = $this->formatAmount((int) ($row['service_fee_amount'] ?? 0)); $row['biz_order_amount_text'] = $this->formatAmount((int) ($row['biz_order_amount'] ?? 0)); $row['biz_paid_amount_text'] = $this->formatAmount((int) ($row['biz_paid_amount'] ?? 0)); $row['biz_refund_amount_text'] = $this->formatAmount((int) ($row['biz_refund_amount'] ?? 0)); @@ -72,9 +71,6 @@ class PayOrderReportService extends BaseService */ public function buildPayTimeline(PayOrder $payOrder): array { - $extJson = (array) ($payOrder->ext_json ?? []); - $lifecycle = (array) ($extJson['lifecycle'] ?? []); - // 只保留真实发生过的节点,未触发的状态直接过滤掉,避免时间线里出现空占位。 return array_values(array_filter([ [ @@ -91,21 +87,19 @@ class PayOrderReportService extends BaseService 'status' => 'closed', 'label' => '支付关闭', 'at' => $this->formatDateTime($payOrder->closed_at, '—'), - // 关闭原因优先取扩展信息里的记录,便于展示人工关单或自动关单原因。 - 'reason' => (string) ($lifecycle['close_reason'] ?? ''), + 'reason' => '', ] : null, $payOrder->failed_at ? [ 'status' => 'failed', 'label' => '支付失败', 'at' => $this->formatDateTime($payOrder->failed_at, '—'), - // 失败原因先看渠道返回,再回落到扩展信息里保存的统一原因字段。 - 'reason' => (string) ($payOrder->channel_error_msg ?: ($extJson['reason'] ?? '')), + 'reason' => (string) $payOrder->channel_error_msg, ] : null, $payOrder->timeout_at ? [ 'status' => 'timeout', 'label' => '支付超时', 'at' => $this->formatDateTime($payOrder->timeout_at, '—'), - 'reason' => (string) ($lifecycle['timeout_reason'] ?? ''), + 'reason' => '', ] : null, ])); } diff --git a/app/service/payment/order/PayOrderRiskControlService.php b/app/service/payment/order/PayOrderRiskControlService.php new file mode 100644 index 0000000..1255134 --- /dev/null +++ b/app/service/payment/order/PayOrderRiskControlService.php @@ -0,0 +1,414 @@ +|null $payOrder 支付单模型或列表行 + * @return bool 是否冻结 + */ + public function isFrozen(PayOrder|array|null $payOrder): bool + { + if (is_array($payOrder) && array_key_exists('freeze_no', $payOrder)) { + return trim((string) ($payOrder['freeze_no'] ?? '')) !== ''; + } + + $payNo = $this->extractPayNo($payOrder); + if ($payNo === '') { + return false; + } + + return $this->fundFreezeRepository->existsActiveByPayNo($payNo, $this->now()); + } + + /** + * 获取冻结展示信息。 + * + * @param PayOrder|array|null $payOrder 支付单模型或列表行 + * @return array 冻结信息 + */ + public function freezeInfo(PayOrder|array|null $payOrder): array + { + if (is_array($payOrder) && trim((string) ($payOrder['freeze_no'] ?? '')) !== '') { + return $this->freezeInfoFromRow($payOrder); + } + + $payNo = $this->extractPayNo($payOrder); + $freeze = $payNo !== '' ? $this->fundFreezeRepository->firstActiveByPayNo($payNo, $this->now()) : null; + if (!$freeze) { + return $this->normalFreezeInfo(); + } + + $status = PayOrderActionConstant::FREEZE_STATUS_FROZEN; + $freezeType = (int) $freeze->freeze_type; + + return [ + 'status' => $status, + 'status_text' => PayOrderActionConstant::freezeStatusMap()[$status] ?? '已冻结', + 'is_frozen' => true, + 'freeze_no' => (string) $freeze->freeze_no, + 'freeze_type' => $freezeType, + 'freeze_type_text' => FundFreezeConstant::typeMap()[$freezeType] ?? '未知', + 'amount' => (int) $freeze->remaining_amount, + 'amount_text' => $this->formatAmount((int) $freeze->remaining_amount), + 'reason' => (string) ($freeze->reason ?? ''), + 'admin_id' => (int) ($freeze->admin_id ?? 0), + 'available_at' => (string) ($freeze->available_at ?? ''), + 'available_at_text' => $this->formatDateTime($freeze->available_at ?? null, '—'), + 'frozen_at' => (string) ($freeze->frozen_at ?? ''), + 'frozen_at_text' => $this->formatDateTime($freeze->frozen_at ?? null, '—'), + 'unfreeze_reason' => (string) ($freeze->release_reason ?? ''), + 'unfrozen_by' => (int) ($freeze->released_by ?? 0), + 'unfrozen_at' => (string) ($freeze->released_at ?? ''), + 'unfrozen_at_text' => $this->formatDateTime($freeze->released_at ?? null, '—'), + ]; + } + + /** + * 从列表查询行拼装冻结展示信息。 + * + * @param array $row 支付单查询行 + * @return array 冻结信息 + */ + private function freezeInfoFromRow(array $row): array + { + $status = PayOrderActionConstant::FREEZE_STATUS_FROZEN; + $freezeType = (int) ($row['freeze_type'] ?? 0); + $amount = (int) ($row['freeze_remaining_amount'] ?? 0); + + return [ + 'status' => $status, + 'status_text' => PayOrderActionConstant::freezeStatusMap()[$status] ?? '已冻结', + 'is_frozen' => true, + 'freeze_no' => (string) ($row['freeze_no'] ?? ''), + 'freeze_type' => $freezeType, + 'freeze_type_text' => FundFreezeConstant::typeMap()[$freezeType] ?? '未知', + 'amount' => $amount, + 'amount_text' => $this->formatAmount($amount), + 'reason' => (string) ($row['freeze_reason'] ?? ''), + 'admin_id' => (int) ($row['freeze_admin_id'] ?? 0), + 'available_at' => (string) ($row['freeze_available_at'] ?? ''), + 'available_at_text' => $this->formatDateTime($row['freeze_available_at'] ?? null, '—'), + 'frozen_at' => (string) ($row['frozen_at'] ?? ''), + 'frozen_at_text' => $this->formatDateTime($row['frozen_at'] ?? null, '—'), + 'unfreeze_reason' => (string) ($row['unfreeze_reason'] ?? ''), + 'unfrozen_by' => (int) ($row['unfrozen_by'] ?? 0), + 'unfrozen_at' => (string) ($row['unfrozen_at'] ?? ''), + 'unfrozen_at_text' => $this->formatDateTime($row['unfrozen_at'] ?? null, '—'), + ]; + } + + /** + * 冻结支付单关联资金。 + * + * @param string $payNo 支付单号 + * @param array $input 冻结参数 + * @param int $adminId 管理员ID + * @return PayOrder 支付单模型 + */ + public function freeze(string $payNo, array $input = [], int $adminId = 0): PayOrder + { + $payNo = trim($payNo); + if ($payNo === '') { + throw new ValidationException('pay_no 不能为空'); + } + + return $this->transactionRetry(function () use ($payNo, $input, $adminId): PayOrder { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + if ($this->fundFreezeRepository->firstActiveForUpdateByPayNo($payNo, $this->now())) { + return $payOrder->refresh(); + } + + $reason = trim((string) ($input['reason'] ?? '')); + if ($reason === '') { + throw new ValidationException('冻结原因不能为空'); + } + + $amount = $this->resolveFreezeAmount($input, $payOrder); + $availableAt = $this->resolveAvailableAt($input); + $freezeNo = $this->generateNo('FRZ'); + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->pay_no); + + // 同一事务内先移动账户余额,再落冻结明细;任一失败都会整体回滚,保证账平。 + $this->merchantAccountService->freezeRiskAmountInCurrentTransaction( + (int) $payOrder->merchant_id, + $amount, + $freezeNo, + 'RISK_FREEZE:' . $freezeNo, + [ + 'freeze_no' => $freezeNo, + 'pay_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'remark' => '风控冻结支付单资金', + ], + $traceNo + ); + + $this->fundFreezeRepository->create([ + 'freeze_no' => $freezeNo, + 'merchant_id' => (int) $payOrder->merchant_id, + 'biz_no' => (string) $payOrder->biz_no, + 'pay_no' => (string) $payOrder->pay_no, + 'trace_no' => $traceNo, + 'freeze_type' => FundFreezeConstant::TYPE_PAY_ORDER, + 'freeze_amount' => $amount, + 'remaining_amount' => $amount, + 'status' => FundFreezeConstant::STATUS_ACTIVE, + 'reason' => $reason, + 'admin_id' => $adminId, + 'available_at' => $availableAt, + 'frozen_at' => $this->now(), + 'release_reason' => '', + 'released_by' => 0, + 'released_at' => null, + ]); + + return $payOrder->refresh(); + }); + } + + /** + * 解冻支付单关联资金。 + * + * @param string $payNo 支付单号 + * @param array $input 解冻参数 + * @param int $adminId 管理员ID + * @return PayOrder 支付单模型 + */ + public function unfreeze(string $payNo, array $input = [], int $adminId = 0): PayOrder + { + $payNo = trim($payNo); + if ($payNo === '') { + throw new ValidationException('pay_no 不能为空'); + } + + return $this->transactionRetry(function () use ($payNo, $input, $adminId): PayOrder { + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $freeze = $this->fundFreezeRepository->firstActiveForUpdateByPayNo($payNo, $this->now()); + if (!$freeze) { + return $payOrder->refresh(); + } + + $reason = trim((string) ($input['reason'] ?? '')); + if ($reason === '') { + throw new ValidationException('解冻原因不能为空'); + } + + $amount = (int) $freeze->remaining_amount; + if ($amount <= 0) { + $this->markFreezeReleased($freeze, $reason, $adminId); + return $payOrder->refresh(); + } + + $this->merchantAccountService->releaseRiskFrozenAmountInCurrentTransaction( + (int) $freeze->merchant_id, + $amount, + (string) $freeze->freeze_no, + 'RISK_RELEASE:' . (string) $freeze->freeze_no, + [ + 'freeze_no' => (string) $freeze->freeze_no, + 'pay_no' => (string) $freeze->pay_no, + 'biz_no' => (string) $freeze->biz_no, + 'remark' => '风控冻结释放', + ], + (string) ($freeze->trace_no ?: $freeze->pay_no) + ); + + $this->markFreezeReleased($freeze, $reason, $adminId); + + return $payOrder->refresh(); + }); + } + + /** + * 确认支付单未被冻结。 + * + * @param PayOrder|array|string $payOrder 支付单、列表行或支付单号 + * @param string $operation 当前操作文案 + * @return void + */ + public function assertNotFrozen(PayOrder|array|string $payOrder, string $operation): void + { + if (is_string($payOrder)) { + $payNo = trim($payOrder); + $payOrder = $payNo !== '' ? $this->payOrderRepository->findByPayNo($payNo) : null; + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + } + + if (!$this->isFrozen($payOrder)) { + return; + } + + throw new BusinessStateException('支付单已冻结,禁止' . $operation, [ + 'pay_no' => $this->extractPayNo($payOrder), + 'freeze_info' => $this->freezeInfo($payOrder), + ]); + } + + /** + * 返回未冻结展示信息。 + * + * @return array 冻结信息 + */ + private function normalFreezeInfo(): array + { + $status = PayOrderActionConstant::FREEZE_STATUS_NORMAL; + + return [ + 'status' => $status, + 'status_text' => PayOrderActionConstant::freezeStatusMap()[$status] ?? '正常', + 'is_frozen' => false, + 'freeze_no' => '', + 'freeze_type' => 0, + 'freeze_type_text' => '', + 'amount' => 0, + 'amount_text' => $this->formatAmount(0), + 'reason' => '', + 'admin_id' => 0, + 'available_at' => '', + 'available_at_text' => '—', + 'frozen_at' => '', + 'frozen_at_text' => '—', + 'unfreeze_reason' => '', + 'unfrozen_by' => 0, + 'unfrozen_at' => '', + 'unfrozen_at_text' => '—', + ]; + } + + /** + * 标记冻结记录已释放。 + * + * @param MerchantFundFreeze $freeze 冻结记录 + * @param string $reason 解冻原因 + * @param int $adminId 管理员ID + * @return void + */ + private function markFreezeReleased(MerchantFundFreeze $freeze, string $reason, int $adminId): void + { + $freeze->remaining_amount = 0; + $freeze->status = FundFreezeConstant::STATUS_RELEASED; + $freeze->release_reason = $reason; + $freeze->released_by = $adminId; + $freeze->released_at = $this->now(); + $freeze->save(); + } + + /** + * 解析冻结金额。 + * + * 未传金额时默认冻结当前支付单金额;传入 `freeze_amount` 表示分,传入 `money` 表示元。 + * + * @param array $input 输入参数 + * @param PayOrder $payOrder 支付单 + * @return int 冻结金额,单位分 + */ + private function resolveFreezeAmount(array $input, PayOrder $payOrder): int + { + if (array_key_exists('freeze_amount', $input) && (int) $input['freeze_amount'] > 0) { + return (int) $input['freeze_amount']; + } + + $money = trim((string) ($input['money'] ?? '')); + if ($money !== '') { + if (!preg_match('/^\d+(\.\d{1,2})?$/', $money)) { + throw new ValidationException('冻结金额格式不正确'); + } + + [$yuan, $cent] = array_pad(explode('.', $money, 2), 2, '0'); + return ((int) $yuan * 100) + (int) str_pad($cent, 2, '0'); + } + + $amount = (int) $payOrder->pay_amount; + if ($amount <= 0) { + throw new ValidationException('冻结金额必须大于 0'); + } + + return $amount; + } + + /** + * 解析最早可释放时间。 + * + * @param array $input 输入参数 + * @return string|null 可释放时间 + */ + private function resolveAvailableAt(array $input): ?string + { + $value = trim((string) ($input['available_at'] ?? $input['release_at'] ?? '')); + if ($value === '') { + return null; + } + + $timestamp = strtotime($value); + if ($timestamp === false) { + throw new ValidationException('可释放时间格式不正确'); + } + + return date('Y-m-d H:i:s', $timestamp); + } + + /** + * 提取支付单号。 + * + * @param PayOrder|array|null $payOrder 支付单模型或列表行 + * @return string 支付单号 + */ + private function extractPayNo(PayOrder|array|null $payOrder): string + { + if ($payOrder instanceof PayOrder) { + return (string) $payOrder->pay_no; + } + + if (is_array($payOrder)) { + return (string) ($payOrder['pay_no'] ?? ''); + } + + return ''; + } +} diff --git a/app/service/payment/order/PayOrderService.php b/app/service/payment/order/PayOrderService.php index 238ac96..bdbb582 100644 --- a/app/service/payment/order/PayOrderService.php +++ b/app/service/payment/order/PayOrderService.php @@ -4,6 +4,7 @@ namespace app\service\payment\order; use app\common\base\BaseService; use app\model\payment\PayOrder; +use app\model\payment\PaymentChannel; use support\Request; use support\Response; @@ -40,11 +41,12 @@ class PayOrderService extends BaseService * @param int $page 页码 * @param int $pageSize 每页条数 * @param int|null $merchantId 商户ID + * @param bool $includeActions 是否返回后台可操作项 * @return array 分页数据 */ - public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array + public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null, bool $includeActions = false): array { - return $this->queryService->paginate($filters, $page, $pageSize, $merchantId); + return $this->queryService->paginate($filters, $page, $pageSize, $merchantId, $includeActions); } /** @@ -52,11 +54,12 @@ class PayOrderService extends BaseService * * @param string $payNo 支付单号 * @param int|null $merchantId 商户ID + * @param bool $includeActions 是否返回后台可操作项 * @return array 订单详情 */ - public function detail(string $payNo, ?int $merchantId = null): array + public function detail(string $payNo, ?int $merchantId = null, bool $includeActions = false): array { - return $this->queryService->detail($payNo, $merchantId); + return $this->queryService->detail($payNo, $merchantId, $includeActions); } /** @@ -70,6 +73,18 @@ class PayOrderService extends BaseService return $this->attemptService->preparePayAttempt($input); } + /** + * 预创建指定通道支付尝试。 + * + * @param array $input 下单数据 + * @param PaymentChannel $channel 指定支付通道 + * @return array 发起结果 + */ + public function preparePayAttemptByChannel(array $input, PaymentChannel $channel): array + { + return $this->attemptService->preparePayAttemptByChannel($input, $channel); + } + /** * 预创建收银台业务单。 * @@ -177,17 +192,6 @@ class PayOrderService extends BaseService return $this->lifecycleService->timeoutPayOrderInCurrentTransaction($payNo, $input); } - /** - * 处理渠道回调。 - * - * @param array $input 回调数据 - * @return PayOrder 支付订单模型 - */ - public function handleChannelCallback(array $input): PayOrder - { - return $this->callbackService->handleChannelCallback($input); - } - /** * 按支付单号处理真实第三方回调。 * @@ -199,6 +203,16 @@ class PayOrderService extends BaseService { return $this->callbackService->handlePluginCallback($payNo, $request); } + + /** + * 按通道处理不携带支付单号的通知。 + * + * @param int $channelId 通道ID + * @param Request $request 请求对象 + * @return string|Response 字符串或响应对象 + */ + public function handleChannelNotify(int $channelId, Request $request): string|Response + { + return $this->callbackService->handleChannelNotify($channelId, $request); + } } - - diff --git a/app/service/payment/order/PaymentPluginNotifyResultValidator.php b/app/service/payment/order/PaymentPluginNotifyResultValidator.php new file mode 100644 index 0000000..332ffa9 --- /dev/null +++ b/app/service/payment/order/PaymentPluginNotifyResultValidator.php @@ -0,0 +1,81 @@ + + */ + protected array $rules = [ + 'status' => 'required|string|in:success,failed,pending', + 'pay_no' => 'nullable|string|max:64', + 'message' => 'nullable|string', + 'channel_order_no' => 'required|string|max:64', + 'channel_trade_no' => 'required|string|max:64', + 'channel_status' => 'nullable|string|max:128', + 'channel_error_code' => 'nullable|string|max:64', + 'channel_error_msg' => 'nullable|string', + 'paid_at' => 'nullable', + 'failed_at' => 'nullable', + ]; + + /** + * 字段别名 + * + * @var array + */ + protected array $attributes = [ + 'status' => '支付状态', + 'pay_no' => '支付单号', + 'message' => '回调说明', + 'channel_order_no' => '渠道订单号', + 'channel_trade_no' => '渠道交易号', + 'channel_status' => '渠道状态', + 'channel_error_code' => '渠道错误码', + 'channel_error_msg' => '渠道错误消息', + 'paid_at' => '支付成功时间', + 'failed_at' => '支付失败时间', + ]; + + /** + * 自定义错误消息 + * + * @var array + */ + protected array $messages = [ + 'status.required' => '插件回调返回 status 不能为空', + 'status.in' => '插件回调返回的状态不合法', + 'channel_order_no.required' => '插件回调返回 channel_order_no 不能为空', + 'channel_trade_no.required' => '插件回调返回 channel_trade_no 不能为空', + ]; + + /** + * 校验场景 + * + * @var array> + */ + protected array $scenes = [ + 'notify_result' => [ + 'status', + 'pay_no', + 'message', + 'channel_order_no', + 'channel_trade_no', + 'channel_status', + 'channel_error_code', + 'channel_error_msg', + 'paid_at', + 'failed_at', + ], + ]; +} diff --git a/app/service/payment/order/PaymentPluginPayResultValidator.php b/app/service/payment/order/PaymentPluginPayResultValidator.php new file mode 100644 index 0000000..dfea387 --- /dev/null +++ b/app/service/payment/order/PaymentPluginPayResultValidator.php @@ -0,0 +1,76 @@ + + */ + protected array $rules = [ + 'pay_page' => 'required|string|in:qrcode,html,jump,jsapi,urlscheme,error,ok,page|max:32', + 'pay_type' => 'required|string|max:32', + 'pay_product' => 'required|string|max:64', + 'pay_action' => 'required|string|max:64', + 'pay_params' => 'required|array', + 'chan_order_no' => 'required_unless:pay_page,error,html,ok|string|max:64', + 'chan_trade_no' => 'nullable|string|max:64', + ]; + + /** + * 字段别名 + * + * @var array + */ + protected array $attributes = [ + 'pay_page' => '承接页类型', + 'pay_type' => '支付方式', + 'pay_product' => '插件支付产品', + 'pay_action' => '插件支付动作', + 'pay_params' => '插件支付参数', + 'chan_order_no' => '渠道订单号', + 'chan_trade_no' => '渠道交易号', + ]; + + /** + * 自定义错误消息 + * + * @var array + */ + protected array $messages = [ + 'pay_page.required' => '插件下单返回 pay_page 不能为空', + 'pay_page.in' => '插件下单返回 pay_page 不支持', + 'pay_type.required' => '插件下单返回 pay_type 不能为空', + 'pay_product.required' => '插件下单返回 pay_product 不能为空', + 'pay_action.required' => '插件下单返回 pay_action 不能为空', + 'pay_params.required' => '插件下单返回 pay_params 不能为空', + 'pay_params.array' => '插件下单返回 pay_params 必须为数组', + 'chan_order_no.required_unless' => '插件下单返回 chan_order_no 不能为空', + ]; + + /** + * 校验场景 + * + * @var array> + */ + protected array $scenes = [ + 'pay_result' => [ + 'pay_page', + 'pay_type', + 'pay_product', + 'pay_action', + 'pay_params', + 'chan_order_no', + 'chan_trade_no', + ], + ]; +} diff --git a/app/service/payment/order/RefundCreationService.php b/app/service/payment/order/RefundCreationService.php index 4af2f9e..7dd7bc5 100644 --- a/app/service/payment/order/RefundCreationService.php +++ b/app/service/payment/order/RefundCreationService.php @@ -21,7 +21,9 @@ use app\repository\payment\trade\RefundOrderRepository; * 负责退款单创建和幂等校验,不承载状态推进逻辑。 * * @property PayOrderRepository $payOrderRepository 支付单仓库 + * @property BizOrderRepository $bizOrderRepository 业务单仓库 * @property RefundOrderRepository $refundOrderRepository 退款单仓库 + * @property PayOrderRiskControlService $payOrderRiskControlService 支付单风控服务 */ class RefundCreationService extends BaseService { @@ -29,13 +31,16 @@ class RefundCreationService extends BaseService * 构造方法。 * * @param PayOrderRepository $payOrderRepository 支付订单仓库 + * @param BizOrderRepository $bizOrderRepository 业务订单仓库 * @param RefundOrderRepository $refundOrderRepository 退款单仓库 + * @param PayOrderRiskControlService $payOrderRiskControlService 支付单风控服务 * @return void */ public function __construct( protected PayOrderRepository $payOrderRepository, protected BizOrderRepository $bizOrderRepository, - protected RefundOrderRepository $refundOrderRepository + protected RefundOrderRepository $refundOrderRepository, + protected PayOrderRiskControlService $payOrderRiskControlService ) { } @@ -58,89 +63,106 @@ class RefundCreationService extends BaseService throw new ValidationException('pay_no 不能为空'); } - // 退款必须先锁定原支付单,确保状态和金额都满足退款前置条件。 - /** @var \app\model\payment\PayOrder|null $payOrder */ - $payOrder = $this->payOrderRepository->findByPayNo($payNo); - if (!$payOrder) { - throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); - } - - // 只有已支付订单才允许发起退款,其他状态直接拒绝。 - if ((int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) { - throw new BusinessStateException('订单状态不允许退款', [ - 'pay_no' => $payNo, - 'status' => (int) $payOrder->status, - ]); - } - - /** @var BizOrder|null $bizOrder */ - $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); - if (!$bizOrder) { - throw new ResourceNotFoundException('业务单不存在', ['biz_no' => (string) $payOrder->biz_no]); - } - - $refundAmount = array_key_exists('refund_amount', $input) - ? (int) $input['refund_amount'] - : (int) $payOrder->pay_amount; - if ($refundAmount <= 0) { - throw new ValidationException('退款金额不合法'); - } - - $alreadyRefunded = (int) $bizOrder->refund_amount; - $remainingRefundable = max(0, (int) $bizOrder->order_amount - $alreadyRefunded); - if ($refundAmount > $remainingRefundable) { - throw new BusinessStateException('退款金额超过可退余额', [ - 'pay_no' => $payNo, - 'refund_amount' => $refundAmount, - 'remaining' => $remainingRefundable, - ]); - } - - // 业务系统若传了商户退款单号,就优先按商户幂等键查重。 - $merchantRefundNo = trim((string) ($input['merchant_refund_no'] ?? '')); - if ($merchantRefundNo !== '') { - // 商户退款单号是第一层幂等键,优先用它判断是否重复提交。 - /** @var RefundOrder|null $existingByMerchantNo */ - $existingByMerchantNo = $this->refundOrderRepository->findByMerchantRefundNo((int) $payOrder->merchant_id, $merchantRefundNo); - if ($existingByMerchantNo) { - if ((string) $existingByMerchantNo->pay_no !== $payNo || (int) $existingByMerchantNo->refund_amount !== $refundAmount) { - throw new ConflictException('幂等冲突', [ - 'refund_no' => (string) $existingByMerchantNo->refund_no, - 'pay_no' => (string) $existingByMerchantNo->pay_no, - 'merchant_refund_no' => $merchantRefundNo, - ]); - } - - return $existingByMerchantNo; + return $this->transactionRetry(function () use ($input, $payNo): RefundOrder { + // 退款必须先锁定原支付单,确保状态和金额都满足退款前置条件。 + /** @var \app\model\payment\PayOrder|null $payOrder */ + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); } - } + $this->payOrderRiskControlService->assertNotFrozen($payOrder, '退款'); - $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + // 只有已支付订单才允许发起退款,其他状态直接拒绝。 + if ((int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) { + throw new BusinessStateException('订单状态不允许退款', [ + 'pay_no' => $payNo, + 'status' => (int) $payOrder->status, + ]); + } - $feeReverseAmount = 0; - if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT && (int) $payOrder->pay_amount > 0) { - $feeReverseAmount = (int) floor(((int) $payOrder->fee_actual_amount) * $refundAmount / max(1, (int) $payOrder->pay_amount)); - } + /** @var BizOrder|null $bizOrder */ + $bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $payOrder->biz_no); + if (!$bizOrder) { + throw new ResourceNotFoundException('业务单不存在', ['biz_no' => (string) $payOrder->biz_no]); + } - return $this->refundOrderRepository->create([ - 'refund_no' => $this->generateNo('RFD'), - 'merchant_id' => (int) $payOrder->merchant_id, - 'merchant_group_id' => (int) $payOrder->merchant_group_id, - 'biz_no' => (string) $payOrder->biz_no, - 'trace_no' => $traceNo, - 'pay_no' => $payNo, - 'merchant_refund_no' => $merchantRefundNo !== '' ? $merchantRefundNo : $this->generateNo('MRF'), - 'channel_id' => (int) $payOrder->channel_id, - 'refund_amount' => $refundAmount, - 'fee_reverse_amount' => $feeReverseAmount, - 'status' => TradeConstant::REFUND_STATUS_CREATED, - 'channel_request_no' => $this->generateNo('RQR'), - 'reason' => (string) ($input['reason'] ?? ''), - 'request_at' => $this->now(), - 'processing_at' => null, - 'retry_count' => 0, - 'last_error' => '', - 'ext_json' => (array) ($input['ext_json'] ?? []), - ]); + $isFullRemainingRefund = (bool) ($input['refund_full_remaining'] ?? false); + $refundAmount = array_key_exists('refund_amount', $input) + ? (int) $input['refund_amount'] + : ($isFullRemainingRefund ? 0 : (int) $payOrder->pay_amount); + + // 业务系统若传了商户退款单号,就优先按商户幂等键查重。 + $merchantRefundNo = trim((string) ($input['merchant_refund_no'] ?? '')); + if ($merchantRefundNo !== '') { + /** @var RefundOrder|null $existingByMerchantNo */ + $existingByMerchantNo = $this->refundOrderRepository->findByMerchantRefundNo((int) $payOrder->merchant_id, $merchantRefundNo); + if ($existingByMerchantNo) { + if ((string) $existingByMerchantNo->pay_no !== $payNo || (int) $existingByMerchantNo->refund_amount !== $refundAmount) { + throw new ConflictException('幂等冲突', [ + 'refund_no' => (string) $existingByMerchantNo->refund_no, + 'pay_no' => (string) $existingByMerchantNo->pay_no, + 'merchant_refund_no' => $merchantRefundNo, + ]); + } + + return $existingByMerchantNo; + } + } + + $reservedRefundAmount = 0; + $reservedRefunds = $this->refundOrderRepository->listForUpdateByPayNoAndStatuses($payNo, [ + TradeConstant::REFUND_STATUS_CREATED, + TradeConstant::REFUND_STATUS_PROCESSING, + TradeConstant::REFUND_STATUS_SUCCESS, + ], ['refund_amount']); + foreach ($reservedRefunds as $reservedRefund) { + $reservedRefundAmount += (int) $reservedRefund->refund_amount; + } + + $remainingRefundable = max(0, (int) $payOrder->pay_amount - $reservedRefundAmount); + if ($isFullRemainingRefund) { + $refundAmount = $remainingRefundable; + } + + if ($refundAmount <= 0) { + throw new ValidationException('退款金额不合法'); + } + + if ($refundAmount > $remainingRefundable) { + throw new BusinessStateException('退款金额超过可退余额', [ + 'pay_no' => $payNo, + 'refund_amount' => $refundAmount, + 'remaining' => $remainingRefundable, + ]); + } + + $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); + + $feeReverseAmount = 0; + if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT && (int) $payOrder->pay_amount > 0) { + $feeReverseAmount = (int) floor(((int) $payOrder->service_fee_amount) * $refundAmount / max(1, (int) $payOrder->pay_amount)); + } + + return $this->refundOrderRepository->create([ + 'refund_no' => $this->generateNo('RFD'), + 'merchant_id' => (int) $payOrder->merchant_id, + 'merchant_group_id' => (int) $payOrder->merchant_group_id, + 'biz_no' => (string) $payOrder->biz_no, + 'trace_no' => $traceNo, + 'pay_no' => $payNo, + 'merchant_refund_no' => $merchantRefundNo !== '' ? $merchantRefundNo : $this->generateNo('MRF'), + 'channel_id' => (int) $payOrder->channel_id, + 'refund_amount' => $refundAmount, + 'fee_reverse_amount' => $feeReverseAmount, + 'status' => TradeConstant::REFUND_STATUS_CREATED, + 'channel_request_no' => $this->generateNo('RQR'), + 'reason' => (string) ($input['reason'] ?? ''), + 'request_at' => $this->now(), + 'processing_at' => null, + 'retry_count' => 0, + 'last_error' => '', + 'ext_json' => (array) ($input['ext_json'] ?? []), + ]); + }); } } diff --git a/app/service/payment/order/RefundDispatchService.php b/app/service/payment/order/RefundDispatchService.php new file mode 100644 index 0000000..2f22fb8 --- /dev/null +++ b/app/service/payment/order/RefundDispatchService.php @@ -0,0 +1,250 @@ +resolveRefundOrder($refund); + $refundNo = (string) $refundOrder->refund_no; + + try { + $refundOrder = $isRetry + ? $this->refundLifecycleService->retryRefund($refundNo, [ + 'reason' => '重新请求上游退款', + 'last_error' => '', + ]) + : $this->refundLifecycleService->markRefundProcessing($refundNo, [ + 'reason' => '请求上游退款', + 'last_error' => '', + ]); + + if ((int) $refundOrder->status === TradeConstant::REFUND_STATUS_SUCCESS) { + return $refundOrder; + } + + if ((int) $refundOrder->status !== TradeConstant::REFUND_STATUS_PROCESSING) { + return $refundOrder; + } + + $payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no); + if (!$payOrder) { + throw new ResourceNotFoundException('原支付单不存在', ['pay_no' => (string) $refundOrder->pay_no]); + } + $this->payOrderRiskControlService->assertNotFrozen($payOrder, '退款派发'); + + $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); + $pluginResult = $plugin->refund($this->buildPluginRefundPayload($payOrder, $refundOrder)); + + if (!$this->isPluginSuccess($pluginResult)) { + $message = (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'); + $refundOrder = $this->refundLifecycleService->markRefundFailed($refundNo, [ + 'failed_at' => $this->now(), + 'last_error' => $message, + 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), + 'ext_json' => [ + 'dispatch' => [ + 'plugin_result' => $this->buildResultSnapshot($pluginResult), + ], + ], + ]); + + if ($throwOnFailure) { + throw new RuntimeException($message); + } + + return $refundOrder; + } + + return $this->refundLifecycleService->markRefundSuccess($refundNo, [ + 'succeeded_at' => $this->now(), + 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), + 'ext_json' => [ + 'dispatch' => [ + 'plugin_result' => $this->buildResultSnapshot($pluginResult), + ], + ], + ]); + } catch (Throwable $e) { + Log::warning(sprintf( + '[RefundDispatch] 退款请求失败 refund_no=%s error=%s', + $refundNo, + $e->getMessage() + )); + + $latest = $this->markDispatchExceptionFailed($refundNo, $e); + if ($throwOnFailure) { + throw $e; + } + + return $latest; + } + } + + /** + * 解析退款单模型。 + * + * @param RefundOrder|string $refund 退款单或退款号 + * @return RefundOrder 退款单模型 + */ + private function resolveRefundOrder(RefundOrder|string $refund): RefundOrder + { + if ($refund instanceof RefundOrder) { + return $refund; + } + + $refundOrder = $this->refundOrderRepository->findByRefundNo($refund); + if (!$refundOrder) { + throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refund]); + } + + return $refundOrder; + } + + /** + * 构建插件退款请求载荷。 + * + * @param PayOrder $payOrder 原支付单 + * @param RefundOrder $refundOrder 退款单 + * @return array 插件退款参数 + */ + private function buildPluginRefundPayload(PayOrder $payOrder, RefundOrder $refundOrder): array + { + return [ + 'order_id' => (string) $payOrder->pay_no, + 'pay_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'chan_order_no' => (string) $payOrder->channel_order_no, + 'chan_trade_no' => (string) $payOrder->channel_trade_no, + 'out_trade_no' => (string) ($payOrder->channel_order_no ?: $payOrder->pay_no), + 'refund_no' => (string) $refundOrder->refund_no, + 'out_refund_no' => (string) $refundOrder->merchant_refund_no, + 'refund_amount' => (int) $refundOrder->refund_amount, + 'refund_reason' => (string) $refundOrder->reason, + 'channel_request_no' => (string) $refundOrder->channel_request_no, + 'extra' => (array) ($payOrder->ext_json ?? []), + ]; + } + + /** + * 判断插件返回是否表示成功。 + * + * @param array $pluginResult 插件结果 + * @return bool 是否成功 + */ + private function isPluginSuccess(array $pluginResult): bool + { + return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success']; + } + + /** + * 从插件返回中提取上游退款单号。 + * + * @param array $pluginResult 插件结果 + * @return string 上游退款单号 + */ + private function resolveRefundChannelNo(array $pluginResult): string + { + foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) { + $value = $pluginResult[$key] ?? ''; + if (is_scalar($value) && trim((string) $value) !== '') { + return trim((string) $value); + } + } + + return ''; + } + + /** + * 构建插件返回快照。 + * + * 原始 raw_data 可能较大或包含敏感响应体,落库时剔除,仅保留定位问题所需字段。 + * + * @param array $pluginResult 插件结果 + * @return array 可落库快照 + */ + private function buildResultSnapshot(array $pluginResult): array + { + unset($pluginResult['raw_data']); + return $pluginResult; + } + + /** + * 将派发异常收口为退款失败状态。 + * + * @param string $refundNo 退款单号 + * @param Throwable $e 异常 + * @return RefundOrder 最新退款单 + */ + private function markDispatchExceptionFailed(string $refundNo, Throwable $e): RefundOrder + { + try { + return $this->refundLifecycleService->markRefundFailed($refundNo, [ + 'failed_at' => $this->now(), + 'last_error' => $e->getMessage() !== '' ? $e->getMessage() : '退款请求异常', + 'ext_json' => [ + 'dispatch' => [ + 'exception' => [ + 'message' => $e->getMessage(), + 'code' => (string) $e->getCode(), + ], + ], + ], + ]); + } catch (Throwable $markException) { + $refundOrder = $this->refundOrderRepository->findByRefundNo($refundNo); + if ($refundOrder) { + return $refundOrder; + } + + throw new ValidationException('退款单状态更新失败:' . $markException->getMessage()); + } + } +} diff --git a/app/service/payment/order/RefundQueryService.php b/app/service/payment/order/RefundQueryService.php index bdf8760..4cc1ab9 100644 --- a/app/service/payment/order/RefundQueryService.php +++ b/app/service/payment/order/RefundQueryService.php @@ -100,7 +100,7 @@ class RefundQueryService extends BaseService // 列表页需要直接显示文案和金额格式,所以在查询层统一做一次格式化。 $list = []; foreach ($paginator->items() as $item) { - $list[] = $this->refundReportService->formatRefundOrderRow((array) $item); + $list[] = $this->refundReportService->formatRefundOrderRow($item->toArray()); } return [ @@ -136,7 +136,7 @@ class RefundQueryService extends BaseService throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]); } - $refundOrder = $this->refundReportService->formatRefundOrderRow((array) $row); + $refundOrder = $this->refundReportService->formatRefundOrderRow($row->toArray()); // 详情页把原始行再转成展示数组,便于前端直接渲染各类状态和金额字段。 $timeline = $this->refundReportService->buildRefundTimeline($row); $accountLedgers = $this->loadRefundLedgers($row); @@ -226,7 +226,7 @@ class RefundQueryService extends BaseService 'po.channel_type', 'po.pay_type_id', 'po.pay_amount as pay_order_amount', - 'po.fee_actual_amount as pay_fee_actual_amount', + 'po.service_fee_amount as pay_service_fee_amount', 'po.status as pay_status', 'bo.merchant_order_no', 'bo.subject', @@ -284,7 +284,7 @@ class RefundQueryService extends BaseService $rows = []; foreach ($ledgers as $ledger) { - $rows[] = $this->refundReportService->formatLedgerRow((array) $ledger); + $rows[] = $this->refundReportService->formatLedgerRow($ledger->toArray()); } return $rows; diff --git a/app/service/payment/order/RefundReportService.php b/app/service/payment/order/RefundReportService.php index 7d1fc0d..7c3d6e3 100644 --- a/app/service/payment/order/RefundReportService.php +++ b/app/service/payment/order/RefundReportService.php @@ -36,7 +36,7 @@ class RefundReportService extends BaseService $row['refund_amount_text'] = $this->formatAmount((int) ($row['refund_amount'] ?? 0)); $row['fee_reverse_amount_text'] = $this->formatAmount((int) ($row['fee_reverse_amount'] ?? 0)); $row['pay_order_amount_text'] = $this->formatAmount((int) ($row['pay_order_amount'] ?? 0)); - $row['pay_fee_actual_amount_text'] = $this->formatAmount((int) ($row['pay_fee_actual_amount'] ?? 0)); + $row['pay_service_fee_amount_text'] = $this->formatAmount((int) ($row['pay_service_fee_amount'] ?? 0)); $row['biz_order_amount_text'] = $this->formatAmount((int) ($row['biz_order_amount'] ?? 0)); $row['biz_paid_amount_text'] = $this->formatAmount((int) ($row['biz_paid_amount'] ?? 0)); $row['biz_refund_amount_text'] = $this->formatAmount((int) ($row['biz_refund_amount'] ?? 0)); @@ -112,4 +112,3 @@ class RefundReportService extends BaseService return $row; } } - diff --git a/app/service/payment/order/RefundService.php b/app/service/payment/order/RefundService.php index d4dfde6..777fdba 100644 --- a/app/service/payment/order/RefundService.php +++ b/app/service/payment/order/RefundService.php @@ -25,7 +25,8 @@ class RefundService extends BaseService public function __construct( protected RefundQueryService $queryService, protected RefundCreationService $creationService, - protected RefundLifecycleService $lifecycleService + protected RefundLifecycleService $lifecycleService, + protected RefundDispatchService $dispatchService ) { } @@ -97,7 +98,7 @@ class RefundService extends BaseService } } - return $this->lifecycleService->retryRefund($refundNo, $input); + return $this->dispatchService->dispatch($refundNo, true); } /** diff --git a/app/service/payment/receipt/ReceiptWatcherService.php b/app/service/payment/receipt/ReceiptWatcherService.php new file mode 100644 index 0000000..4063422 --- /dev/null +++ b/app/service/payment/receipt/ReceiptWatcherService.php @@ -0,0 +1,555 @@ + 执行摘要 + */ + public function refreshChannelCache(): array + { + $pluginCodes = $this->supportedPluginCodes(); + if ($pluginCodes === []) { + $this->clearQueryTasks(); + Redis::del(self::ACCOUNTS_KEY); + return [ + 'accounts' => 0, + 'channels' => 0, + ]; + } + + $channels = $this->paymentChannelRepository->listReceiptWatcherChannels($pluginCodes); + + $configIds = $channels->pluck('api_config_id')->map(fn ($id): int => (int) $id)->filter()->unique()->values()->all(); + $payTypeIds = $channels->pluck('pay_type_id')->map(fn ($id): int => (int) $id)->filter()->unique()->values()->all(); + $configs = $this->paymentPluginConfRepository->listByIds($configIds)->keyBy('id'); + $payTypes = $this->paymentTypeRepository->listByIds($payTypeIds)->keyBy('id'); + + $accounts = []; + foreach ($channels as $channel) { + $config = $configs->get((int) $channel->api_config_id); + if (!$config || (string) $config->plugin_code !== (string) $channel->plugin_code) { + continue; + } + + $payType = $payTypes->get((int) $channel->pay_type_id); + $accountKey = $this->accountKey((string) $channel->plugin_code, (int) $channel->api_config_id); + if (!isset($accounts[$accountKey])) { + $pluginConfig = (array) ($config->config ?? []); + $accounts[$accountKey] = [ + 'account_key' => $accountKey, + 'plugin_code' => (string) $channel->plugin_code, + 'api_config_id' => (int) $channel->api_config_id, + 'merchant_id' => (int) $config->merchant_id, + 'config' => $pluginConfig, + 'query_interval_seconds' => $this->queryIntervalSeconds($pluginConfig), + 'channels' => [], + 'refreshed_at' => time(), + ]; + } + + $accounts[$accountKey]['channels'][] = [ + 'channel_id' => (int) $channel->id, + 'merchant_id' => (int) $channel->merchant_id, + 'name' => (string) $channel->name, + 'pay_type_id' => (int) $channel->pay_type_id, + 'pay_type' => (string) ($payType->code ?? ''), + 'pay_type_name' => (string) ($payType->name ?? ''), + 'terminal_no' => (string) (($accounts[$accountKey]['config']['receipt_terminal_no'] ?? '') ?: ''), + ]; + } + + Redis::del(self::ACCOUNTS_KEY); + foreach ($accounts as $accountKey => $account) { + Redis::hSet(self::ACCOUNTS_KEY, $accountKey, $this->jsonEncode($account)); + } + $this->removeStaleQueryTasks(array_keys($accounts)); + + return [ + 'accounts' => count($accounts), + 'channels' => array_sum(array_map(static fn (array $account): int => count($account['channels']), $accounts)), + ]; + } + + /** + * 同步待支付订单到 Redis 查询任务。 + * + * @param int $limit 扫描订单数量 + * @return array 执行摘要 + */ + public function syncPendingOrders(int $limit = 500): array + { + if (!$this->watcherEnabled()) { + $this->clearQueryTasks(); + return [ + 'scanned' => 0, + 'accounts' => 0, + 'orders' => 0, + ]; + } + + $accounts = $this->accountMap(); + if ($accounts === []) { + $this->refreshChannelCache(); + $accounts = $this->accountMap(); + } + if ($accounts === []) { + $this->clearQueryTasks(); + return [ + 'scanned' => 0, + 'accounts' => 0, + 'orders' => 0, + ]; + } + + $pluginCodes = array_values(array_unique(array_map(static fn (array $account): string => (string) $account['plugin_code'], $accounts))); + $orders = $this->payOrderRepository->listReceiptWatcherPendingOrders($pluginCodes, $this->now(), $limit); + + $ordersByAccount = []; + foreach ($orders as $order) { + $accountKey = $this->accountKey((string) $order->plugin_code, (int) $order->api_config_id); + if (!isset($accounts[$accountKey])) { + continue; + } + + $ordersByAccount[$accountKey][] = [ + 'pay_no' => (string) $order->pay_no, + 'channel_id' => (int) $order->channel_id, + 'pay_type_id' => (int) $order->pay_type_id, + 'pay_type' => (string) ($order->pay_type ?? ''), + 'pay_amount' => (int) $order->pay_amount, + 'channel_order_no' => (string) ($order->channel_order_no ?? ''), + 'channel_trade_no' => (string) ($order->channel_trade_no ?? ''), + 'request_at' => (string) $order->request_at, + 'expire_at' => (string) ($order->expire_at ?? ''), + 'created_at' => (string) $order->created_at, + ]; + } + + foreach ($accounts as $accountKey => $account) { + $accountOrders = $ordersByAccount[$accountKey] ?? []; + if ($accountOrders === []) { + $this->removeAccountQueryTask($accountKey); + continue; + } + + $this->storeAccountOrders($account, $accountOrders); + } + + return [ + 'scanned' => count($orders), + 'accounts' => count($ordersByAccount), + 'orders' => array_sum(array_map('count', $ordersByAccount)), + ]; + } + + /** + * 支付单进入终态后清理对应账号查询任务。 + * + * @param PayOrder $payOrder 支付单 + * @return void + */ + public function cleanupPayOrder(PayOrder $payOrder): void + { + $channel = $this->paymentChannelRepository->find((int) $payOrder->channel_id); + if (!$channel || (int) $channel->api_config_id <= 0) { + return; + } + + $accountKey = $this->accountKey((string) $channel->plugin_code, (int) $channel->api_config_id); + $ordersKey = $this->ordersKey($accountKey); + Redis::hDel($ordersKey, (string) $payOrder->pay_no); + + if ((int) Redis::hLen($ordersKey) <= 0) { + $this->removeAccountQueryTask($accountKey); + return; + } + + $taskKey = $this->accountTaskKey($accountKey); + $raw = Redis::get($taskKey); + $meta = is_string($raw) && $raw !== '' ? json_decode($raw, true) : []; + if (is_array($meta)) { + $meta['order_count'] = (int) Redis::hLen($ordersKey); + Redis::setEx($taskKey, max(60, (int) Redis::ttl($taskKey)), $this->jsonEncode($meta)); + } + } + + /** + * 根据流水支付方式解析具体通道。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param string $payTypeCode 支付方式编码 + * @return PaymentChannel|null 支付通道 + */ + public function resolveChannelForFlow(string $pluginCode, int $apiConfigId, string $payTypeCode): ?PaymentChannel + { + return $this->paymentChannelRepository->findReceiptFlowChannel($pluginCode, $apiConfigId, $payTypeCode); + } + + /** + * 判断流水是否已经处理成功。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $record 流水记录 + * @return bool 是否已处理 + */ + public function isFlowSeen(string $pluginCode, int $apiConfigId, array $record): bool + { + return (bool) Redis::exists($this->flowSeenKey($pluginCode, $apiConfigId, $record)); + } + + /** + * 标记流水已处理成功。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $record 流水记录 + * @return void + */ + public function markFlowSeen(string $pluginCode, int $apiConfigId, array $record): void + { + Redis::setEx($this->flowSeenKey($pluginCode, $apiConfigId, $record), 30 * 86400, '1'); + } + + /** + * 获取流水处理锁。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $record 流水记录 + * @return string|null 锁令牌 + */ + public function acquireFlowLock(string $pluginCode, int $apiConfigId, array $record): ?string + { + $key = $this->flowLockKey($pluginCode, $apiConfigId, $record); + $token = bin2hex(random_bytes(8)); + if (!Redis::setNx($key, $token)) { + return null; + } + + Redis::expire($key, 30); + return $token; + } + + /** + * 释放流水处理锁。 + * + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $record 流水记录 + * @param string $token 锁令牌 + * @return void + */ + public function releaseFlowLock(string $pluginCode, int $apiConfigId, array $record, string $token): void + { + $key = $this->flowLockKey($pluginCode, $apiConfigId, $record); + if ((string) Redis::get($key) === $token) { + Redis::del($key); + } + } + + /** + * 获取支持网页流水监听的插件编码。 + * + * @return array 插件编码列表 + */ + private function supportedPluginCodes(): array + { + $raw = (string) sys_config('receipt_watcher_plugin_codes', ''); + $parts = preg_split('/[\s,,;;]+/', $raw) ?: []; + $codes = []; + foreach ($parts as $part) { + $code = trim((string) $part); + if ($code !== '') { + $codes[] = $code; + } + } + + return array_values(array_unique($codes)); + } + + /** + * 判断网页流水监听是否启用。 + * + * @return bool 是否启用 + */ + private function watcherEnabled(): bool + { + $value = strtolower(trim((string) sys_config('receipt_watcher_enabled', '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } + + /** + * 读取账号缓存。 + * + * @return array> 账号映射 + */ + private function accountMap(): array + { + $raw = Redis::hGetAll(self::ACCOUNTS_KEY); + if (!is_array($raw) || $raw === []) { + return []; + } + + $accounts = []; + foreach ($raw as $key => $value) { + $decoded = is_string($value) ? json_decode($value, true) : null; + if (is_array($decoded)) { + $accounts[(string) $key] = $decoded; + } + } + + return $accounts; + } + + /** + * 写入账号订单与查询任务。 + * + * @param array $account 账号缓存 + * @param array> $orders 订单列表 + * @return void + */ + private function storeAccountOrders(array $account, array $orders): void + { + $accountKey = (string) $account['account_key']; + $ordersKey = $this->ordersKey($accountKey); + Redis::del($ordersKey); + foreach ($orders as $order) { + Redis::hSet($ordersKey, (string) $order['pay_no'], $this->jsonEncode($order)); + } + + $expireAt = $this->maxExpireTimestamp($orders); + $ttl = max(60, $expireAt - time() + 60); + $meta = [ + 'account_key' => $accountKey, + 'plugin_code' => (string) $account['plugin_code'], + 'api_config_id' => (int) $account['api_config_id'], + 'query_interval_seconds' => $this->queryIntervalSeconds((array) ($account['config'] ?? [])), + 'order_count' => count($orders), + 'expire_at' => $expireAt, + 'updated_at' => time(), + ]; + Redis::setEx($this->accountTaskKey($accountKey), $ttl, $this->jsonEncode($meta)); + Redis::expire($ordersKey, $ttl); + + $score = Redis::zScore(self::QUERY_ACCOUNTS_KEY, $accountKey); + if ($score === false || $score === null) { + Redis::zAdd(self::QUERY_ACCOUNTS_KEY, time(), $accountKey); + } + } + + /** + * 移除账号查询任务。 + * + * @param string $accountKey 账号键 + * @return void + */ + private function removeAccountQueryTask(string $accountKey): void + { + Redis::zRem(self::QUERY_ACCOUNTS_KEY, $accountKey); + Redis::del($this->accountTaskKey($accountKey)); + Redis::del($this->ordersKey($accountKey)); + } + + /** + * 清理所有账号查询任务。 + * + * @return void + */ + private function clearQueryTasks(): void + { + try { + $accountKeys = Redis::zRange(self::QUERY_ACCOUNTS_KEY, 0, -1); + if (is_array($accountKeys)) { + foreach ($accountKeys as $accountKey) { + $this->removeAccountQueryTask((string) $accountKey); + } + } + Redis::del(self::QUERY_ACCOUNTS_KEY); + } catch (Throwable $e) { + Log::warning('[ReceiptWatcherService] 清理查询任务失败:' . $e->getMessage()); + } + } + + /** + * 移除已经不在账号缓存中的查询任务。 + * + * @param array $activeAccountKeys 当前有效账号键 + * @return void + */ + private function removeStaleQueryTasks(array $activeAccountKeys): void + { + $active = array_fill_keys($activeAccountKeys, true); + $accountKeys = Redis::zRange(self::QUERY_ACCOUNTS_KEY, 0, -1); + if (!is_array($accountKeys)) { + return; + } + + foreach ($accountKeys as $accountKey) { + $accountKey = (string) $accountKey; + if (!isset($active[$accountKey])) { + $this->removeAccountQueryTask($accountKey); + } + } + } + + /** + * @param array> $orders 订单列表 + * @return int 最大过期时间戳 + */ + private function maxExpireTimestamp(array $orders): int + { + $timestamps = []; + foreach ($orders as $order) { + $expireAt = trim((string) ($order['expire_at'] ?? '')); + $timestamp = $expireAt !== '' ? strtotime($expireAt) : false; + if ($timestamp !== false && $timestamp > 0) { + $timestamps[] = (int) $timestamp; + } + } + + return $timestamps === [] ? time() + 600 : max($timestamps); + } + + /** + * @param array $config 插件配置 + * @return int 查询间隔秒数 + */ + private function queryIntervalSeconds(array $config): int + { + return max(2, (int) ($config['receipt_watcher_query_interval_seconds'] ?? 3)); + } + + /** + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @return string 账号键 + */ + private function accountKey(string $pluginCode, int $apiConfigId): string + { + return $this->safeKeyPart($pluginCode) . '_' . $apiConfigId; + } + + /** + * @param string $accountKey 账号键 + * @return string 账号任务键 + */ + private function accountTaskKey(string $accountKey): string + { + return self::ACCOUNT_TASK_KEY_PREFIX . $this->safeKeyPart($accountKey); + } + + /** + * @param string $accountKey 账号键 + * @return string 订单集合键 + */ + private function ordersKey(string $accountKey): string + { + return self::ORDERS_KEY_PREFIX . $this->safeKeyPart($accountKey); + } + + /** + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $record 流水记录 + * @return string 幂等键 + */ + private function flowSeenKey(string $pluginCode, int $apiConfigId, array $record): string + { + return self::FLOW_SEEN_KEY_PREFIX . $this->flowIdentity($pluginCode, $apiConfigId, $record); + } + + /** + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $record 流水记录 + * @return string 锁键 + */ + private function flowLockKey(string $pluginCode, int $apiConfigId, array $record): string + { + return self::FLOW_LOCK_KEY_PREFIX . $this->flowIdentity($pluginCode, $apiConfigId, $record); + } + + /** + * @param string $pluginCode 插件编码 + * @param int $apiConfigId 插件配置ID + * @param array $record 流水记录 + * @return string 流水身份 + */ + private function flowIdentity(string $pluginCode, int $apiConfigId, array $record): string + { + $orderNo = trim((string) ($record['order_no'] ?? '')); + if ($orderNo === '') { + throw new RuntimeException('流水订单号不能为空'); + } + + return $this->safeKeyPart($pluginCode . '_' . $apiConfigId . '_' . $orderNo); + } + + /** + * @param string $value 原始键片段 + * @return string 安全键片段 + */ + private function safeKeyPart(string $value): string + { + $safe = preg_replace('/[^A-Za-z0-9_\\-]/', '_', $value) ?? ''; + + return trim($safe, '_') !== '' ? trim($safe, '_') : 'empty'; + } + + /** + * @param array $payload 载荷 + * @return string JSON 字符串 + */ + private function jsonEncode(array $payload): string + { + return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'; + } +} diff --git a/app/service/payment/runtime/MerchantNotifyDispatcherService.php b/app/service/payment/runtime/MerchantNotifyDispatcherService.php index 80908aa..901bbde 100644 --- a/app/service/payment/runtime/MerchantNotifyDispatcherService.php +++ b/app/service/payment/runtime/MerchantNotifyDispatcherService.php @@ -4,6 +4,7 @@ namespace app\service\payment\runtime; use app\common\base\BaseService; use app\common\constant\AuthConstant; +use app\common\constant\EpayProtocolConstant; use app\common\constant\EventConstant; use app\common\constant\NotifyConstant; use app\common\util\FormatHelper; @@ -20,7 +21,9 @@ use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; use app\service\payment\config\PaymentTypeService; use app\service\payment\epay\EpaySignerManager; +use app\service\payment\order\PayOrderRiskControlService; use GuzzleHttp\Client; +use RuntimeException; use support\Log; use Throwable; use Webman\Event\Event; @@ -32,10 +35,6 @@ use Webman\Event\Event; */ class MerchantNotifyDispatcherService extends BaseService { - private const PROTOCOL_V1 = 'v1'; - private const PROTOCOL_V2 = 'v2'; - private const SUCCESS_RESPONSE = 'success'; - private Client $httpClient; public function __construct( @@ -45,7 +44,9 @@ class MerchantNotifyDispatcherService extends BaseService protected PayOrderRepository $payOrderRepository, protected MerchantApiCredentialRepository $merchantApiCredentialRepository, protected PaymentTypeService $paymentTypeService, - protected EpaySignerManager $signerManager + protected EpaySignerManager $signerManager, + protected PaymentQueueService $paymentQueueService, + protected PayOrderRiskControlService $payOrderRiskControlService ) { $this->httpClient = new Client([ 'timeout' => 10, @@ -56,15 +57,23 @@ class MerchantNotifyDispatcherService extends BaseService } /** - * 为支付成功创建通知任务,并立即尝试派发一次。 + * 为支付成功创建通知任务。 * * @param PayOrder $payOrder 支付单 * @param BizOrder|null $bizOrder 业务单 * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null * @throws ValidationException */ - public function enqueueAndDispatchPaySuccess(PayOrder $payOrder, ?BizOrder $bizOrder = null): ?NotifyTask + public function enqueuePaySuccess(PayOrder $payOrder, ?BizOrder $bizOrder = null): ?NotifyTask { + if (!$this->merchantNotifyEnabled()) { + return null; + } + + if ($this->payOrderRiskControlService->isFrozen($payOrder)) { + return null; + } + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); $notifyUrl = trim((string) ($payOrder->notify_url ?: ($bizOrder?->notify_url ?? ''))); if ($notifyUrl === '') { @@ -83,19 +92,80 @@ class MerchantNotifyDispatcherService extends BaseService 'status' => NotifyConstant::TASK_STATUS_PENDING, ]); - return $this->dispatchTask($task); + return $task; } /** - * 为退款成功创建通知任务,并立即尝试派发一次。 + * 后台手动重新创建支付成功通知任务。 + * + * 与自动通知不同,手动重新通知不复用历史 notify_task,避免成功过的任务被 + * dispatchTask 幂等返回,导致后台点击后没有真正再次通知商户。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @param int $adminId 管理员ID + * @param string $reason 操作原因 + * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null + */ + public function enqueueManualPaySuccess(PayOrder $payOrder, ?BizOrder $bizOrder = null, int $adminId = 0, string $reason = ''): ?NotifyTask + { + if (!$this->merchantNotifyEnabled()) { + return null; + } + + $this->payOrderRiskControlService->assertNotFrozen($payOrder, '重新通知'); + + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $notifyUrl = trim((string) ($payOrder->notify_url ?: ($bizOrder?->notify_url ?? ''))); + if ($notifyUrl === '') { + return null; + } + + return $this->notifyService->enqueueMerchantNotify([ + 'merchant_id' => (int) $payOrder->merchant_id, + 'merchant_group_id' => (int) $payOrder->merchant_group_id, + 'event_type' => NotifyConstant::EVENT_PAY_SUCCESS, + 'ref_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'pay_no' => (string) $payOrder->pay_no, + 'notify_url' => $notifyUrl, + 'notify_data' => $this->buildPaySuccessPayload($payOrder, $bizOrder), + 'status' => NotifyConstant::TASK_STATUS_PENDING, + ], false); + } + + /** + * 为支付成功创建通知任务,并立即尝试派发一次。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null + * @throws ValidationException + */ + public function enqueueAndDispatchPaySuccess(PayOrder $payOrder, ?BizOrder $bizOrder = null): ?NotifyTask + { + $task = $this->enqueuePaySuccess($payOrder, $bizOrder); + return $task ? $this->dispatchTask($task) : null; + } + + /** + * 为退款成功创建通知任务。 * * @param RefundOrder $refundOrder 退款单 * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null */ - public function enqueueAndDispatchRefundSuccess(RefundOrder $refundOrder): ?NotifyTask + public function enqueueRefundSuccess(RefundOrder $refundOrder): ?NotifyTask { + if (!$this->merchantNotifyEnabled()) { + return null; + } + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no); $payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no); + if ($payOrder && $this->payOrderRiskControlService->isFrozen($payOrder)) { + return null; + } + $notifyUrl = trim((string) ($payOrder?->notify_url ?: ($bizOrder?->notify_url ?? ''))); if ($notifyUrl === '') { return null; @@ -113,19 +183,35 @@ class MerchantNotifyDispatcherService extends BaseService 'status' => NotifyConstant::TASK_STATUS_PENDING, ]); - return $this->dispatchTask($task); + return $task; } /** - * 为清算完成创建通知任务,并立即尝试派发一次。 + * 为退款成功创建通知任务,并立即尝试派发一次。 + * + * @param RefundOrder $refundOrder 退款单 + * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null + */ + public function enqueueAndDispatchRefundSuccess(RefundOrder $refundOrder): ?NotifyTask + { + $task = $this->enqueueRefundSuccess($refundOrder); + return $task ? $this->dispatchTask($task) : null; + } + + /** + * 为清算完成创建通知任务。 * * 当前清算单只有在 ext_json.notify_url 明确存在时才通知商户。 * * @param SettlementOrder $settlementOrder 清算单 * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null */ - public function enqueueAndDispatchSettlementSuccess(SettlementOrder $settlementOrder): ?NotifyTask + public function enqueueSettlementSuccess(SettlementOrder $settlementOrder): ?NotifyTask { + if (!$this->merchantNotifyEnabled()) { + return null; + } + $extJson = (array) ($settlementOrder->ext_json ?? []); $notifyUrl = trim((string) ($extJson['notify_url'] ?? '')); if ($notifyUrl === '') { @@ -144,43 +230,75 @@ class MerchantNotifyDispatcherService extends BaseService 'status' => NotifyConstant::TASK_STATUS_PENDING, ]); - return $this->dispatchTask($task); + return $task; + } + + /** + * 为清算完成创建通知任务,并立即尝试派发一次。 + * + * 当前清算单只有在 ext_json.notify_url 明确存在时才通知商户。 + * + * @param SettlementOrder $settlementOrder 清算单 + * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null + */ + public function enqueueAndDispatchSettlementSuccess(SettlementOrder $settlementOrder): ?NotifyTask + { + $task = $this->enqueueSettlementSuccess($settlementOrder); + return $task ? $this->dispatchTask($task) : null; } /** * 派发单个通知任务。 * * @param NotifyTask|string $task 通知任务模型或通知号 - * @return NotifyTask + * @param bool $throwOnFailure 通知失败时是否抛出异常 + * @return NotifyTask 最新通知任务 * @throws ResourceNotFoundException */ - public function dispatchTask(NotifyTask|string $task): NotifyTask + public function dispatchTask(NotifyTask|string $task, bool $throwOnFailure = false): NotifyTask { $task = $this->resolveTask($task); if ((int) $task->status === NotifyConstant::TASK_STATUS_SUCCESS) { return $task; } + if (!$this->merchantNotifyEnabled()) { + $task->last_response = '商户通知已关闭,暂停派发'; + $task->save(); + + return $task->refresh(); + } + + if ($this->pauseTaskIfPayOrderFrozen($task)) { + return $task->refresh(); + } + $eventName = EventConstant::MERCHANT_NOTIFY_FAILED; + $failureMessage = ''; try { + $timeout = $this->intConfig('pay_notify_request_timeout_seconds', 10, 1, 60); $response = $this->httpClient->request('GET', (string) $task->notify_url, [ 'query' => (array) ($task->notify_data ?? []), + 'timeout' => $timeout, + 'connect_timeout' => $timeout, ]); $body = trim((string) $response->getBody()); - if (strtolower($body) === self::SUCCESS_RESPONSE) { + if (strtolower($body) === NotifyConstant::MERCHANT_SUCCESS_RESPONSE) { $task = $this->notifyService->markTaskSuccess((string) $task->notify_no, [ 'last_notify_at' => $this->now(), 'last_response' => $this->truncateResponse($body), ]); $eventName = EventConstant::MERCHANT_NOTIFY_SUCCEEDED; } else { + $failureMessage = $body !== '' ? $body : '商户未返回 success'; $task = $this->notifyService->markTaskFailed((string) $task->notify_no, [ 'last_notify_at' => $this->now(), - 'last_response' => $this->truncateResponse($body !== '' ? $body : '商户未返回 success'), + 'last_response' => $this->truncateResponse($failureMessage), ]); } } catch (Throwable $e) { + $failureMessage = $e->getMessage(); Log::warning(sprintf( '[MerchantNotify] 派发失败 notify_no=%s pay_no=%s error=%s', (string) $task->notify_no, @@ -195,18 +313,28 @@ class MerchantNotifyDispatcherService extends BaseService } $this->dispatchNotifyTaskEvent($eventName, $task); + if ($throwOnFailure && (int) $task->status !== NotifyConstant::TASK_STATUS_SUCCESS) { + throw new RuntimeException($failureMessage !== '' ? $failureMessage : '商户通知失败'); + } return $task; } /** - * 批量重试到期任务。 + * 批量投递到期重试任务。 + * + * 到期任务只重新入队,不在维护进程里同步请求商户,避免定时进程被 + * 商户 notify_url 的网络耗时拖住。 * * @param int $limit 最大处理数量 - * @return int 实际处理数量 + * @return int 实际投递数量 */ public function dispatchRetryableTasks(int $limit = 100): int { + if (!$this->merchantNotifyEnabled()) { + return 0; + } + $limit = max(1, $limit); $count = 0; @@ -215,8 +343,16 @@ class MerchantNotifyDispatcherService extends BaseService break; } - $this->dispatchTask($task); - $count++; + try { + $this->paymentQueueService->sendMerchantNotify((string) $task->notify_no); + $count++; + } catch (Throwable $e) { + Log::warning(sprintf( + '[MerchantNotify] 重试任务投递队列失败 notify_no=%s error=%s', + (string) $task->notify_no, + $e->getMessage() + )); + } } return $count; @@ -233,8 +369,8 @@ class MerchantNotifyDispatcherService extends BaseService private function buildPaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array { return match ($this->resolveProtocolVersion($payOrder, $bizOrder)) { - self::PROTOCOL_V1 => $this->buildV1PaySuccessPayload($payOrder, $bizOrder), - self::PROTOCOL_V2 => $this->buildV2PaySuccessPayload($payOrder, $bizOrder), + EpayProtocolConstant::VERSION_V1 => $this->buildV1PaySuccessPayload($payOrder, $bizOrder), + EpayProtocolConstant::VERSION_V2 => $this->buildV2PaySuccessPayload($payOrder, $bizOrder), default => throw new ValidationException('订单未记录协议版本,无法发送商户通知'), }; } @@ -290,7 +426,7 @@ class MerchantNotifyDispatcherService extends BaseService 'endtime' => FormatHelper::dateTime($settlementOrder->completed_at ?: $this->now()), ]; $extJson = (array) ($settlementOrder->ext_json ?? []); - $protocol = strtolower(trim((string) ($extJson['_protocol_version'] ?? self::PROTOCOL_V2))); + $protocol = strtolower(trim((string) ($extJson['_protocol_version'] ?? EpayProtocolConstant::VERSION_V2))); return $this->signEventPayload($payload, $protocol, (int) $settlementOrder->merchant_id); } @@ -308,7 +444,7 @@ class MerchantNotifyDispatcherService extends BaseService $bizExtJson = (array) (($bizOrder?->ext_json) ?? []); $version = strtolower(trim((string) ($payExtJson['_protocol_version'] ?? $bizExtJson['_protocol_version'] ?? ''))); - return in_array($version, [self::PROTOCOL_V1, self::PROTOCOL_V2], true) ? $version : ''; + return in_array($version, [EpayProtocolConstant::VERSION_V1, EpayProtocolConstant::VERSION_V2], true) ? $version : ''; } /** @@ -328,7 +464,7 @@ class MerchantNotifyDispatcherService extends BaseService } $payload = $this->buildBasePaySuccessPayload($payOrder, $bizOrder); - $payload['trade_status'] = 'TRADE_SUCCESS'; + $payload['trade_status'] = NotifyConstant::EPAY_TRADE_STATUS_SUCCESS; $payload['sign_type'] = AuthConstant::API_SIGN_NAME_MD5; $payload['sign'] = $this->signerManager->sign($this->signPayload($payload), AuthConstant::API_SIGN_NAME_MD5, $apiKey); @@ -350,9 +486,9 @@ class MerchantNotifyDispatcherService extends BaseService throw new ValidationException('平台 RSA 私钥未配置,无法发送 V2 通知'); } - $signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA); + $signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_RSA); $payload = $this->buildBasePaySuccessPayload($payOrder, $bizOrder); - $payload['trade_status'] = 'TRADE_SUCCESS'; + $payload['trade_status'] = NotifyConstant::EPAY_TRADE_STATUS_SUCCESS; $payload['addtime'] = FormatHelper::dateTime($payOrder->created_at); $payload['endtime'] = FormatHelper::dateTime($payOrder->paid_at ?: $this->now()); $payload['timestamp'] = (string) time(); @@ -372,7 +508,7 @@ class MerchantNotifyDispatcherService extends BaseService */ private function signEventPayload(array $payload, string $protocol, int $merchantId): array { - if ($protocol === self::PROTOCOL_V1) { + if ($protocol === EpayProtocolConstant::VERSION_V1) { $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); $apiKey = trim((string) ($credential?->api_key ?? '')); if ($apiKey === '') { @@ -390,7 +526,7 @@ class MerchantNotifyDispatcherService extends BaseService throw new ValidationException('平台 RSA 私钥未配置,无法发送 V2 通知'); } - $signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA); + $signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_RSA); $payload['timestamp'] = (string) time(); $payload['sign_type'] = $signType; $payload['sign'] = $this->signerManager->sign($this->signPayload($payload), $signType, $privateKey); @@ -468,6 +604,33 @@ class MerchantNotifyDispatcherService extends BaseService return $taskModel; } + /** + * 支付单冻结时暂停商户通知。 + * + * 已经入队但尚未派发的任务也要在这里兜底拦截,避免冻结后队列消费者继续 + * 请求商户。任务保持原状态,解冻后可通过后台重新通知。 + * + * @param NotifyTask $task 通知任务 + * @return bool 是否已暂停 + */ + private function pauseTaskIfPayOrderFrozen(NotifyTask $task): bool + { + $payNo = trim((string) ($task->pay_no ?? '')); + if ($payNo === '') { + return false; + } + + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if (!$payOrder || !$this->payOrderRiskControlService->isFrozen($payOrder)) { + return false; + } + + $task->last_response = '支付单已冻结,暂停商户通知'; + $task->save(); + + return true; + } + /** * 发送商户通知任务事件。 * @@ -522,4 +685,44 @@ class MerchantNotifyDispatcherService extends BaseService return mb_strlen($body) > $length ? mb_substr($body, 0, $length) : $body; } + + /** + * 商户通知是否启用。 + * + * @return bool 是否启用 + */ + private function merchantNotifyEnabled(): bool + { + return $this->boolConfig('pay_notify_enabled', true); + } + + /** + * 读取布尔配置。 + * + * @param string $key 配置键 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function boolConfig(string $key, bool $default): bool + { + $value = strtolower(trim((string) sys_config($key, $default ? '1' : '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } + + /** + * 读取整数配置。 + * + * @param string $key 配置键 + * @param int $default 默认值 + * @param int $min 最小值 + * @param int $max 最大值 + * @return int 整数值 + */ + private function intConfig(string $key, int $default, int $min, int $max): int + { + $value = (int) sys_config($key, $default); + + return min($max, max($min, $value)); + } } diff --git a/app/service/payment/runtime/NotifyService.php b/app/service/payment/runtime/NotifyService.php index a32b874..621b10e 100644 --- a/app/service/payment/runtime/NotifyService.php +++ b/app/service/payment/runtime/NotifyService.php @@ -94,11 +94,15 @@ class NotifyService extends BaseService * 不在日志层吞掉后续通知。 * * @param array $input 回调数据 - * @return PayCallbackLog 支付回调日志 + * @return PayCallbackLog|null 支付回调日志;关闭日志时返回 null * @throws ValidationException */ - public function recordPayCallback(array $input): PayCallbackLog + public function recordPayCallback(array $input): ?PayCallbackLog { + if (!$this->boolConfig('pay_callback_log_enabled', true)) { + return null; + } + $payNo = trim((string) ($input['pay_no'] ?? '')); if ($payNo === '') { throw new ValidationException('pay_no 不能为空', ['pay_no' => $payNo]); @@ -126,9 +130,10 @@ class NotifyService extends BaseService * 通常用于支付成功、退款成功或清算完成后的商户异步通知。 * * @param array $input 通知任务数据 + * @param bool $deduplicate 是否按事件引用单号去重 * @return NotifyTask 通知任务 */ - public function enqueueMerchantNotify(array $input): NotifyTask + public function enqueueMerchantNotify(array $input, bool $deduplicate = true): NotifyTask { $eventType = (string) ($input['event_type'] ?? NotifyConstant::EVENT_PAY_SUCCESS); $refNo = (string) ($input['ref_no'] ?? $input['pay_no'] ?? ''); @@ -136,9 +141,11 @@ class NotifyService extends BaseService throw new ValidationException('通知事件引用单号不能为空'); } - $existing = $this->notifyTaskRepository->findByEventRef($eventType, $refNo); - if ($existing) { - return $existing; + if ($deduplicate) { + $existing = $this->notifyTaskRepository->findByEventRef($eventType, $refNo); + if ($existing) { + return $existing; + } } return $this->notifyTaskRepository->create([ @@ -265,6 +272,20 @@ class NotifyService extends BaseService return max(1, (int) $this->systemConfigRuntimeService->get('pay_notify_retry_interval', 10)); } + /** + * 读取布尔配置。 + * + * @param string $key 配置键 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function boolConfig(string $key, bool $default): bool + { + $value = strtolower(trim((string) $this->systemConfigRuntimeService->get($key, $default ? '1' : '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } + /** * 生成稳定的载荷摘要,用于后台识别重复通知。 * diff --git a/app/service/payment/runtime/PaymentPluginFactoryService.php b/app/service/payment/runtime/PaymentPluginFactoryService.php index ae548e6..52ca76f 100644 --- a/app/service/payment/runtime/PaymentPluginFactoryService.php +++ b/app/service/payment/runtime/PaymentPluginFactoryService.php @@ -79,6 +79,33 @@ class PaymentPluginFactoryService extends BaseService return $instance; } + /** + * 根据渠道创建转账插件实例。 + * + * 转账能力由插件 transfer_types 声明,不再用支付方式 pay_types 做拦截。 + * + * @param PaymentChannel|int $channel 渠道对象或渠道ID + * @param bool $allowDisabled 是否允许已禁用插件 + * @return PaymentInterface&PayPluginInterface 插件实例 + * @throws PaymentException + */ + public function createTransferByChannel(PaymentChannel|int $channel, bool $allowDisabled = false): PaymentInterface & PayPluginInterface + { + $channelModel = $channel instanceof PaymentChannel + ? $channel + : $this->paymentChannelRepository->find((int) $channel); + + if (!$channelModel) { + throw new PaymentException('支付通道不存在', 40402, ['channel_id' => (int) $channel]); + } + + $plugin = $this->resolvePlugin((string) $channelModel->plugin_code, $allowDisabled); + $instance = $this->instantiatePlugin((string) $plugin->class_name); + $instance->init($this->buildChannelConfig($channelModel, $plugin)); + + return $instance; + } + /** * 根据支付订单创建支付插件实例。 * diff --git a/app/service/payment/runtime/PaymentPluginManager.php b/app/service/payment/runtime/PaymentPluginManager.php index 6893b6e..08b01df 100644 --- a/app/service/payment/runtime/PaymentPluginManager.php +++ b/app/service/payment/runtime/PaymentPluginManager.php @@ -39,6 +39,18 @@ class PaymentPluginManager extends BaseService return $this->factoryService->createByChannel($channel, $payTypeId, $allowDisabled); } + /** + * 根据渠道创建转账插件实例。 + * + * @param PaymentChannel|int $channel 渠道对象或渠道ID + * @param bool $allowDisabled 是否允许已禁用插件 + * @return PaymentInterface&PayPluginInterface 插件实例 + */ + public function createTransferByChannel(PaymentChannel|int $channel, bool $allowDisabled = false): PaymentInterface & PayPluginInterface + { + return $this->factoryService->createTransferByChannel($channel, $allowDisabled); + } + /** * 根据支付订单创建支付插件实例。 * @@ -77,4 +89,3 @@ class PaymentPluginManager extends BaseService } - diff --git a/app/service/payment/runtime/PaymentQueueService.php b/app/service/payment/runtime/PaymentQueueService.php new file mode 100644 index 0000000..0ac76c4 --- /dev/null +++ b/app/service/payment/runtime/PaymentQueueService.php @@ -0,0 +1,115 @@ +send(PaymentQueueConstant::MERCHANT_NOTIFY, [ + 'notify_no' => $notifyNo, + ], $delay); + } + + /** + * 投递退款通道请求任务。 + * + * @param string $refundNo 退款单号 + * @param bool $isRetry 是否为重试派发 + * @param int $delay 延迟秒数 + * @return bool 是否投递成功 + */ + public function sendRefundDispatch(string $refundNo, bool $isRetry = false, int $delay = 0): bool + { + return $this->send(PaymentQueueConstant::REFUND_DISPATCH, [ + 'refund_no' => $refundNo, + 'is_retry' => $isRetry, + ], $delay); + } + + /** + * 投递转账通道派发任务。 + * + * @param string $bizNo 转账单号 + * @param int $delay 延迟秒数 + * @return bool 是否投递成功 + */ + public function sendTransferDispatch(string $bizNo, int $delay = 0): bool + { + return $this->send(PaymentQueueConstant::TRANSFER_DISPATCH, [ + 'biz_no' => $bizNo, + ], $delay); + } + + /** + * 投递转账查单任务。 + * + * @param string $bizNo 转账单号 + * @param int $attempt 当前查单次数 + * @param int $delay 延迟秒数 + * @return bool 是否投递成功 + */ + public function sendTransferQuery(string $bizNo, int $attempt = 0, int $delay = 0): bool + { + return $this->send(PaymentQueueConstant::TRANSFER_QUERY, [ + 'biz_no' => $bizNo, + 'attempt' => max(0, $attempt), + ], $delay); + } + + /** + * 投递清算自动入账任务。 + * + * @param string $settleNo 清算单号 + * @param int $delay 延迟秒数 + * @return bool 是否投递成功 + */ + public function sendSettlementComplete(string $settleNo, int $delay = 0): bool + { + return $this->send(PaymentQueueConstant::SETTLEMENT_COMPLETE, [ + 'settle_no' => $settleNo, + ], $delay); + } + + /** + * 投递网页流水监听通知任务。 + * + * @param array $payload 已归一化的流水载荷 + * @param int $delay 延迟秒数 + * @return bool 是否投递成功 + */ + public function sendReceiptFlowNotify(array $payload, int $delay = 0): bool + { + return $this->send(PaymentQueueConstant::RECEIPT_FLOW_NOTIFY, $payload, $delay); + } + + /** + * 投递原始队列消息。 + * + * @param string $queue 队列名 + * @param array $data 载荷 + * @param int $delay 延迟秒数 + * @return bool 是否投递成功 + */ + public function send(string $queue, array $data, int $delay = 0): bool + { + return Redis::send($queue, $data, max(0, $delay)); + } +} diff --git a/app/service/payment/runtime/PaymentRouteResolverService.php b/app/service/payment/runtime/PaymentRouteResolverService.php index 30950cf..b721d82 100644 --- a/app/service/payment/runtime/PaymentRouteResolverService.php +++ b/app/service/payment/runtime/PaymentRouteResolverService.php @@ -149,6 +149,7 @@ class PaymentRouteResolverService extends BaseService 'pay_type_id' => (int) ($row->pay_type_id ?? 0), 'code' => (string) ($row->pay_type_code ?? ''), 'name' => (string) ($row->pay_type_name ?? ''), + 'icon' => (string) ($row->pay_type_icon ?? ''), 'selected_channel_id' => (int) $channel->id, 'selected_channel_name' => (string) $channel->name, 'selected_channel_mode' => (int) $channel->channel_mode, diff --git a/app/service/payment/runtime/PaymentRuntimeMaintenanceService.php b/app/service/payment/runtime/PaymentRuntimeMaintenanceService.php index eb6f9d4..747986a 100644 --- a/app/service/payment/runtime/PaymentRuntimeMaintenanceService.php +++ b/app/service/payment/runtime/PaymentRuntimeMaintenanceService.php @@ -4,12 +4,13 @@ namespace app\service\payment\runtime; use app\common\base\BaseService; use app\common\constant\PaymentPluginStatusConstant; -use app\common\constant\TradeConstant; use app\exception\PaymentException; +use app\exception\ResourceNotFoundException; use app\model\payment\PayOrder; use app\repository\payment\trade\PayOrderRepository; use app\service\payment\config\PaymentTypeService; use app\service\payment\order\PayOrderLifecycleService; +use app\service\payment\order\PayOrderRiskControlService; use support\Log; /** @@ -27,13 +28,15 @@ class PaymentRuntimeMaintenanceService extends BaseService * @param PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务 * @param PaymentPluginManager $paymentPluginManager 支付插件管理器 * @param PaymentTypeService $paymentTypeService 支付方式服务 + * @param PayOrderRiskControlService $payOrderRiskControlService 支付单风控服务 */ public function __construct( protected MerchantNotifyDispatcherService $merchantNotifyDispatcherService, protected PayOrderRepository $payOrderRepository, protected PayOrderLifecycleService $payOrderLifecycleService, protected PaymentPluginManager $paymentPluginManager, - protected PaymentTypeService $paymentTypeService + protected PaymentTypeService $paymentTypeService, + protected PayOrderRiskControlService $payOrderRiskControlService ) { } @@ -71,11 +74,6 @@ class PaymentRuntimeMaintenanceService extends BaseService try { $this->payOrderLifecycleService->timeoutPayOrder((string) $payOrder->pay_no, [ 'reason' => '系统定时任务检测到支付单已过期', - 'ext_json' => [ - 'lifecycle' => [ - 'timeout_source' => 'runtime_process', - ], - ], ]); $summary['timeout']++; } catch (\Throwable $e) { @@ -107,69 +105,17 @@ class PaymentRuntimeMaintenanceService extends BaseService 'failed' => 0, 'closed' => 0, 'pending' => 0, + 'skipped' => 0, 'error' => 0, ]; foreach ($this->payOrderRepository->listPayingForActiveQuery($before, $limit) as $payOrder) { $summary['scanned']++; - - try { - $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); - $result = $plugin->query($this->buildQueryOrder($payOrder)); - $normalized = $this->normalizeQueryResult($payOrder, $result); - - if ($normalized['status'] === PaymentPluginStatusConstant::SUCCESS) { - $this->payOrderLifecycleService->markPaySuccess((string) $payOrder->pay_no, [ - 'channel_order_no' => $normalized['channel_order_no'], - 'channel_trade_no' => $normalized['channel_trade_no'], - 'paid_at' => $normalized['paid_at'] ?: null, - 'ext_json' => [ - 'plugin' => [ - 'active_query' => $this->buildQuerySnapshot($normalized, $result), - ], - ], - ]); - $summary['success']++; - continue; - } - - if ($normalized['status'] === PaymentPluginStatusConstant::CLOSED) { - $this->payOrderLifecycleService->closePayOrder((string) $payOrder->pay_no, [ - 'reason' => '主动查单返回渠道已关闭', - 'ext_json' => [ - 'plugin' => [ - 'active_query' => $this->buildQuerySnapshot($normalized, $result), - ], - ], - ]); - $summary['closed']++; - continue; - } - - if ($normalized['status'] === PaymentPluginStatusConstant::FAILED) { - $this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [ - 'channel_order_no' => $normalized['channel_order_no'], - 'channel_trade_no' => $normalized['channel_trade_no'], - 'channel_error_code' => $normalized['channel_error_code'], - 'channel_error_msg' => $normalized['channel_error_msg'], - 'failed_at' => $normalized['failed_at'] ?: null, - 'ext_json' => [ - 'plugin' => [ - 'active_query' => $this->buildQuerySnapshot($normalized, $result), - ], - ], - ]); - $summary['failed']++; - continue; - } - - $this->recordQuerySnapshot($payOrder, $this->buildQuerySnapshot($normalized, $result)); - $summary['pending']++; - } catch (PaymentException $e) { - $this->recordQueryError($payOrder, $e->getMessage(), (string) $e->getCode()); - $summary['error']++; - } catch (\Throwable $e) { - $this->recordQueryError($payOrder, $e->getMessage(), 'QUERY_ERROR'); + $result = $this->syncOnePayOrderByQuery($payOrder, 'runtime_active_query'); + $status = (string) ($result['status'] ?? 'error'); + if (array_key_exists($status, $summary)) { + $summary[$status]++; + } else { $summary['error']++; } } @@ -177,6 +123,115 @@ class PaymentRuntimeMaintenanceService extends BaseService return $summary; } + /** + * 主动查询单笔支付单。 + * + * 后台人工查单允许查询非支付中订单,用于处理本地失败/关闭/超时后上游实际成功的情况; + * 查询结果仍然会通过支付单生命周期服务推进,避免绕开平台服务费和业务单同步逻辑。 + * + * @param string $payNo 支付单号 + * @param string $source 查单来源 + * @return array 查单结果 + */ + public function syncPayOrderByQuery(string $payNo, string $source = 'admin_manual_query'): array + { + $payNo = trim($payNo); + $payOrder = $payNo !== '' ? $this->payOrderRepository->findByPayNo($payNo) : null; + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + return $this->syncOnePayOrderByQuery($payOrder, $source); + } + + /** + * 查询单笔支付单并按结果推进状态。 + * + * @param PayOrder $payOrder 支付单 + * @param string $source 查单来源 + * @return array 查单结果 + */ + private function syncOnePayOrderByQuery(PayOrder $payOrder, string $source = 'runtime_active_query'): array + { + $payNo = (string) $payOrder->pay_no; + if ($this->payOrderRiskControlService->isFrozen($payOrder)) { + return [ + 'pay_no' => $payNo, + 'status' => 'skipped', + 'message' => '支付单已冻结,跳过主动查单', + ]; + } + + try { + $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); + $result = $plugin->query($this->buildQueryOrder($payOrder)); + $normalized = $this->normalizeQueryResult($payOrder, $result); + $snapshot = $this->buildQuerySnapshot($normalized, $result, $source); + + if ($normalized['status'] === PaymentPluginStatusConstant::SUCCESS) { + $this->payOrderLifecycleService->markPaySuccess($payNo, [ + 'channel_order_no' => $normalized['channel_order_no'], + 'channel_trade_no' => $normalized['channel_trade_no'], + 'paid_at' => $normalized['paid_at'] ?: null, + ]); + + return [ + 'pay_no' => $payNo, + 'status' => 'success', + 'snapshot' => $snapshot, + ]; + } + + if ($normalized['status'] === PaymentPluginStatusConstant::CLOSED) { + $this->payOrderLifecycleService->closePayOrder($payNo, [ + 'reason' => '主动查单返回渠道已关闭', + ]); + + return [ + 'pay_no' => $payNo, + 'status' => 'closed', + 'snapshot' => $snapshot, + ]; + } + + if ($normalized['status'] === PaymentPluginStatusConstant::FAILED) { + $this->payOrderLifecycleService->markPayFailed($payNo, [ + 'channel_order_no' => $normalized['channel_order_no'], + 'channel_trade_no' => $normalized['channel_trade_no'], + 'channel_error_code' => $normalized['channel_error_code'], + 'channel_error_msg' => $normalized['channel_error_msg'], + 'failed_at' => $normalized['failed_at'] ?: null, + ]); + + return [ + 'pay_no' => $payNo, + 'status' => 'failed', + 'snapshot' => $snapshot, + ]; + } + + return [ + 'pay_no' => $payNo, + 'status' => 'pending', + 'snapshot' => $snapshot, + ]; + } catch (PaymentException $e) { + $snapshot = $this->recordQueryError($payOrder, $e->getMessage(), (string) $e->getCode(), $source); + return [ + 'pay_no' => $payNo, + 'status' => 'error', + 'snapshot' => $snapshot, + ]; + } catch (\Throwable $e) { + $snapshot = $this->recordQueryError($payOrder, $e->getMessage(), 'QUERY_ERROR', $source); + return [ + 'pay_no' => $payNo, + 'status' => 'error', + 'snapshot' => $snapshot, + ]; + } + } + /** * 构建插件查单参数。 * @@ -244,16 +299,18 @@ class PaymentRuntimeMaintenanceService extends BaseService } /** - * 构建支付单内保存的轻量查单快照。 + * 构建本次主动查单的返回快照。 * * @param array $normalized 归一化结果 * @param array $result 插件原始结果 + * @param string $source 查单来源 * @return array 快照 */ - private function buildQuerySnapshot(array $normalized, array $result): array + private function buildQuerySnapshot(array $normalized, array $result, string $source = 'runtime_active_query'): array { return [ 'queried_at' => $this->now(), + 'source' => $source, 'status' => (string) $normalized['status'], 'raw_status' => (string) ($normalized['raw_status'] ?? ''), 'channel_status' => (string) ($normalized['channel_status'] ?? ''), @@ -265,42 +322,15 @@ class PaymentRuntimeMaintenanceService extends BaseService } /** - * 记录支付中订单的查单快照。 - * - * @param PayOrder $payOrder 支付单 - * @param array $snapshot 查单快照 - * @return void - */ - private function recordQuerySnapshot(PayOrder $payOrder, array $snapshot): void - { - $this->transactionRetry(function () use ($payOrder, $snapshot): void { - $latest = $this->payOrderRepository->findForUpdateByPayNo((string) $payOrder->pay_no); - if (!$latest || (int) $latest->status !== TradeConstant::ORDER_STATUS_PAYING) { - return; - } - - $extJson = (array) ($latest->ext_json ?? []); - $plugin = (array) ($extJson['plugin'] ?? []); - $previous = (array) ($plugin['active_query'] ?? []); - $snapshot['query_count'] = (int) ($previous['query_count'] ?? 0) + 1; - - $extJson['plugin'] = array_replace_recursive($plugin, [ - 'active_query' => $snapshot, - ]); - $latest->ext_json = $extJson; - $latest->save(); - }); - } - - /** - * 记录主动查单异常,异常不推进支付状态。 + * 构建主动查单异常快照,异常不推进支付状态。 * * @param PayOrder $payOrder 支付单 * @param string $message 错误信息 * @param string $code 错误码 - * @return void + * @param string $source 查单来源 + * @return array 异常快照 */ - private function recordQueryError(PayOrder $payOrder, string $message, string $code): void + private function recordQueryError(PayOrder $payOrder, string $message, string $code, string $source = 'runtime_active_query'): array { Log::warning(sprintf( '[PaymentRuntimeMaintenance] 主动查单失败 pay_no=%s code=%s error=%s', @@ -309,8 +339,9 @@ class PaymentRuntimeMaintenanceService extends BaseService $message )); - $this->recordQuerySnapshot($payOrder, [ + $snapshot = [ 'queried_at' => $this->now(), + 'source' => $source, 'status' => 'error', 'raw_status' => '', 'channel_status' => '', @@ -319,7 +350,8 @@ class PaymentRuntimeMaintenanceService extends BaseService 'error_code' => $code, 'channel_order_no' => (string) ($payOrder->channel_order_no ?? ''), 'channel_trade_no' => (string) ($payOrder->channel_trade_no ?? ''), - ]); + ]; + return $snapshot; } /** diff --git a/app/service/payment/settlement/SettlementAutomationService.php b/app/service/payment/settlement/SettlementAutomationService.php new file mode 100644 index 0000000..17a914e --- /dev/null +++ b/app/service/payment/settlement/SettlementAutomationService.php @@ -0,0 +1,168 @@ +status !== TradeConstant::ORDER_STATUS_SUCCESS) { + return null; + } + + if ((int) $payOrder->channel_type !== RouteConstant::CHANNEL_MODE_COLLECT) { + return null; + } + + $payNo = (string) $payOrder->pay_no; + $payAmount = (int) $payOrder->pay_amount; + $serviceFee = (int) $payOrder->service_fee_amount; + $netAmount = max(0, $payAmount - $serviceFee); + + return $this->settlementService->createSettlementOrder([ + 'settle_no' => $this->settleNoForPayNo($payNo), + 'trace_no' => (string) ($payOrder->trace_no ?: $payOrder->biz_no), + 'merchant_id' => (int) $payOrder->merchant_id, + 'merchant_group_id' => (int) $payOrder->merchant_group_id, + 'channel_id' => (int) $payOrder->channel_id, + 'cycle_type' => $this->resolveCycleType($payOrder), + 'cycle_key' => $payNo, + 'accounted_amount' => $netAmount, + 'status' => TradeConstant::SETTLEMENT_STATUS_PENDING, + 'ext_json' => [], + ], [[ + 'pay_no' => $payNo, + 'refund_no' => '', + 'pay_amount' => $payAmount, + 'fee_amount' => $serviceFee, + 'refund_amount' => 0, + 'fee_reverse_amount' => 0, + 'net_amount' => $netAmount, + 'item_status' => TradeConstant::SETTLEMENT_STATUS_PENDING, + ]]); + } + + /** + * 判断清算单是否允许自动入账。 + * + * @param SettlementOrder $settlementOrder 清算单 + * @return bool 是否允许自动入账 + */ + public function shouldAutoComplete(SettlementOrder $settlementOrder): bool + { + if ((int) $settlementOrder->status !== TradeConstant::SETTLEMENT_STATUS_PENDING) { + return false; + } + + $policy = $this->merchantPolicyRepository->findByMerchantId((int) $settlementOrder->merchant_id); + if (!$policy || (int) $policy->auto_payout !== 1) { + return false; + } + + $minSettlementAmount = (int) $policy->min_settlement_amount; + if ($minSettlementAmount > 0 && (int) $settlementOrder->accounted_amount < $minSettlementAmount) { + return false; + } + + return true; + } + + /** + * 执行自动清算入账。 + * + * @param string $settleNo 清算单号 + * @return SettlementOrder|null 清算单 + */ + public function completeAutoSettlement(string $settleNo): ?SettlementOrder + { + $settlementOrder = $this->settlementOrderRepository->findBySettleNo($settleNo); + if (!$settlementOrder) { + return null; + } + + if (!$this->shouldAutoComplete($settlementOrder)) { + return $settlementOrder; + } + + return $this->settlementService->completeSettlement($settleNo); + } + + /** + * 按支付单生成稳定清算单号。 + * + * @param string $payNo 支付单号 + * @return string 清算单号 + */ + private function settleNoForPayNo(string $payNo): string + { + return 'STL' . substr($payNo, 3); + } + + /** + * 解析清算周期。 + * + * 商户策略优先,其次使用通道绑定的插件配置,最后默认 D1。 + * + * @param PayOrder $payOrder 支付单 + * @return int 清算周期 + */ + private function resolveCycleType(PayOrder $payOrder): int + { + $policy = $this->merchantPolicyRepository->findByMerchantId((int) $payOrder->merchant_id); + if ($policy) { + return (int) $policy->settlement_cycle_override; + } + + $channel = $this->paymentChannelRepository->find((int) $payOrder->channel_id); + if (!$channel || (int) $channel->api_config_id <= 0) { + return TradeConstant::SETTLEMENT_CYCLE_D1; + } + + $pluginConf = $this->paymentPluginConfRepository->find((int) $channel->api_config_id); + + return $pluginConf ? (int) $pluginConf->settlement_cycle_type : TradeConstant::SETTLEMENT_CYCLE_D1; + } +} diff --git a/app/service/payment/settlement/SettlementLifecycleService.php b/app/service/payment/settlement/SettlementLifecycleService.php index 2d15ef5..96d27b2 100644 --- a/app/service/payment/settlement/SettlementLifecycleService.php +++ b/app/service/payment/settlement/SettlementLifecycleService.php @@ -12,7 +12,9 @@ use app\model\payment\SettlementOrder; use app\repository\payment\settlement\SettlementItemRepository; use app\repository\payment\settlement\SettlementOrderRepository; use app\repository\payment\trade\PayOrderRepository; +use app\repository\payment\trade\RefundOrderRepository; use app\service\account\funds\MerchantAccountService; +use app\service\payment\order\PayOrderRiskControlService; use Webman\Event\Event; /** @@ -23,7 +25,9 @@ use Webman\Event\Event; * @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库 * @property SettlementItemRepository $settlementItemRepository 结算明细仓库 * @property PayOrderRepository $payOrderRepository 支付单仓库 + * @property RefundOrderRepository $refundOrderRepository 退款单仓库 * @property MerchantAccountService $merchantAccountService 商户账户服务 + * @property PayOrderRiskControlService $payOrderRiskControlService 支付单风控服务 */ class SettlementLifecycleService extends BaseService { @@ -33,14 +37,18 @@ class SettlementLifecycleService extends BaseService * @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库 * @param SettlementItemRepository $settlementItemRepository 结算明细仓库 * @param PayOrderRepository $payOrderRepository 支付订单仓库 + * @param RefundOrderRepository $refundOrderRepository 退款订单仓库 * @param MerchantAccountService $merchantAccountService 商户账户服务 + * @param PayOrderRiskControlService $payOrderRiskControlService 支付单风控服务 * @return void */ public function __construct( protected SettlementOrderRepository $settlementOrderRepository, protected SettlementItemRepository $settlementItemRepository, protected PayOrderRepository $payOrderRepository, - protected MerchantAccountService $merchantAccountService + protected RefundOrderRepository $refundOrderRepository, + protected MerchantAccountService $merchantAccountService, + protected PayOrderRiskControlService $payOrderRiskControlService ) { } @@ -160,6 +168,24 @@ class SettlementLifecycleService extends BaseService ]); } + $items = $this->settlementItemRepository->listBySettleNo($settleNo); + $lockedPayOrders = []; + foreach ($items as $item) { + $payNo = trim((string) ($item->pay_no ?? '')); + if ($payNo === '') { + continue; + } + + $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); + if ($payOrder) { + // 清算入账前锁定并检查支付单,避免冻结单继续入账。 + $this->payOrderRiskControlService->assertNotFrozen($payOrder, '清算入账'); + $lockedPayOrders[$payNo] = $payOrder; + } + } + + $this->refreshSettlementAmountsBeforeAccount($settlementOrder, $items, $lockedPayOrders); + if ((int) $settlementOrder->accounted_amount > 0) { // 只有净额大于 0 时才入账到商户可提现余额。 $this->merchantAccountService->creditAvailableAmountInCurrentTransaction( @@ -180,14 +206,13 @@ class SettlementLifecycleService extends BaseService $settlementOrder->completed_at = $this->now(); $settlementOrder->save(); - $items = $this->settlementItemRepository->listBySettleNo($settleNo); foreach ($items as $item) { // 清算明细和关联支付单状态一起同步,避免批次与订单状态不一致。 $item->item_status = TradeConstant::SETTLEMENT_STATUS_SETTLED; $item->save(); if (!empty($item->pay_no)) { - $payOrder = $this->payOrderRepository->findByPayNo((string) $item->pay_no); + $payOrder = $lockedPayOrders[(string) $item->pay_no] ?? null; if ($payOrder) { $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_SETTLED; $payOrder->save(); @@ -274,6 +299,73 @@ class SettlementLifecycleService extends BaseService return $settlementOrder; } + /** + * 入账前按支付单和已成功退款重算清算金额。 + * + * @param SettlementOrder $settlementOrder 清算单 + * @param iterable $items 清算明细 + * @param array $lockedPayOrders 已锁定支付单 + * @return void + */ + private function refreshSettlementAmountsBeforeAccount(SettlementOrder $settlementOrder, iterable $items, array $lockedPayOrders): void + { + $grossAmount = 0; + $feeAmount = 0; + $refundAmount = 0; + $feeReverseAmount = 0; + $netAmount = 0; + $hasPayItems = false; + + foreach ($items as $item) { + $payNo = trim((string) ($item->pay_no ?? '')); + $payOrder = $payNo !== '' ? ($lockedPayOrders[$payNo] ?? null) : null; + if (!$payOrder) { + continue; + } + + $hasPayItems = true; + $itemPayAmount = (int) $payOrder->pay_amount; + $itemFeeAmount = (int) $payOrder->service_fee_amount; + $itemRefundAmount = 0; + $itemFeeReverseAmount = 0; + + $refunds = $this->refundOrderRepository->listForUpdateByPayNoAndStatuses($payNo, [ + TradeConstant::REFUND_STATUS_SUCCESS, + ], ['refund_amount', 'fee_reverse_amount']); + foreach ($refunds as $refund) { + $itemRefundAmount += (int) $refund->refund_amount; + $itemFeeReverseAmount += (int) $refund->fee_reverse_amount; + } + + $itemNetAmount = max(0, $itemPayAmount - $itemFeeAmount - $itemRefundAmount + $itemFeeReverseAmount); + + $item->pay_amount = $itemPayAmount; + $item->fee_amount = $itemFeeAmount; + $item->refund_amount = $itemRefundAmount; + $item->fee_reverse_amount = $itemFeeReverseAmount; + $item->net_amount = $itemNetAmount; + $item->save(); + + $grossAmount += $itemPayAmount; + $feeAmount += $itemFeeAmount; + $refundAmount += $itemRefundAmount; + $feeReverseAmount += $itemFeeReverseAmount; + $netAmount += $itemNetAmount; + } + + if (!$hasPayItems) { + return; + } + + $settlementOrder->gross_amount = $grossAmount; + $settlementOrder->fee_amount = $feeAmount; + $settlementOrder->refund_amount = $refundAmount; + $settlementOrder->fee_reverse_amount = $feeReverseAmount; + $settlementOrder->net_amount = $netAmount; + $settlementOrder->accounted_amount = $netAmount; + $settlementOrder->save(); + } + /** * 发送清算单事件。 * diff --git a/app/service/payment/transfer/TransferService.php b/app/service/payment/transfer/TransferService.php index 0f7ef8d..d2b9e9e 100644 --- a/app/service/payment/transfer/TransferService.php +++ b/app/service/payment/transfer/TransferService.php @@ -3,31 +3,68 @@ namespace app\service\payment\transfer; use app\common\base\BaseService; +use app\common\constant\CommonConstant; use app\common\constant\TransferConstant; +use app\common\interface\TransferPluginInterface; use app\exception\ConflictException; use app\exception\ResourceNotFoundException; use app\exception\ValidationException; use app\model\merchant\Merchant; +use app\model\payment\PaymentChannel; use app\model\payment\TransferOrder; use app\repository\account\balance\MerchantAccountRepository; +use app\repository\payment\config\PaymentChannelRepository; use app\repository\payment\trade\TransferOrderRepository; +use app\service\account\funds\MerchantAccountService; +use app\service\payment\runtime\PaymentPluginManager; +use app\service\payment\runtime\PaymentQueueService; +use support\Log; +use Throwable; /** * 转账服务。 + * + * 负责 ePay 转账入口的参数校验、幂等创建、商户余额扣减、通道路由、 + * 队列派发上游转账请求和非终态查单同步。 */ class TransferService extends BaseService { + /** + * 自动查单最大次数。 + */ + private const QUERY_MAX_ATTEMPTS = 5; + + /** + * 自动查单退避间隔。 + */ + private const QUERY_DELAY_SECONDS = [60, 120, 300, 600, 900]; + + /** + * 构造方法。 + * + * @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库 + * @param TransferOrderRepository $transferOrderRepository 转账单仓库 + * @param PaymentChannelRepository $paymentChannelRepository 支付通道仓库 + * @param PaymentPluginManager $paymentPluginManager 支付插件管理器 + * @param MerchantAccountService $merchantAccountService 商户账户服务 + * @param PaymentQueueService $paymentQueueService 支付队列服务 + * @return void + */ public function __construct( protected MerchantAccountRepository $merchantAccountRepository, - protected TransferOrderRepository $transferOrderRepository + protected TransferOrderRepository $transferOrderRepository, + protected PaymentChannelRepository $paymentChannelRepository, + protected PaymentPluginManager $paymentPluginManager, + protected MerchantAccountService $merchantAccountService, + protected PaymentQueueService $paymentQueueService ) { } /** - * 创建转账单。 + * 创建并发起转账单。 * * @param Merchant $merchant 商户 - * @param array $input 请求参数 + * @param array $input 请求参数 * @return array */ public function submit(Merchant $merchant, array $input): array @@ -60,50 +97,115 @@ class TransferService extends BaseService ]); } - return $this->formatTransferOrder($existing); + return $this->formatTransferOrder($this->syncTransferOrderIfNeeded($existing)); } } + [$channel] = $this->resolveTransferChannelAndPlugin($merchantId, $type, (int) ($input['channel_id'] ?? 0)); $transferRate = $this->resolveTransferRate(); $costAmount = (int) floor($amount * $transferRate); $bizNo = $this->generateNo('TRF'); $traceNo = $this->generateNo('TRC'); + $totalDebit = $amount + $costAmount; + $created = false; /** @var TransferOrder $transferOrder */ - $transferOrder = $this->transferOrderRepository->create([ - 'biz_no' => $bizNo, - 'trace_no' => $traceNo, - 'merchant_id' => $merchantId, - 'merchant_group_id' => (int) ($merchant->group_id ?? 0), - 'out_biz_no' => $outBizNo !== '' ? $outBizNo : $this->generateNo('OBN'), - 'type' => $type, - 'account' => $account, - 'name' => $name, - 'amount' => $amount, - 'cost_amount' => $costAmount, - 'remark' => (string) ($input['remark'] ?? ''), - 'bookid' => (string) ($input['bookid'] ?? ''), - 'channel_id' => (int) ($input['channel_id'] ?? 0), - 'channel_request_no' => $this->generateNo('TRQ'), - 'status' => TransferConstant::TRANSFER_STATUS_PENDING, - 'request_at' => $this->now(), - 'ext_json' => (array) ($input['ext_json'] ?? []), - ]); + $transferOrder = $this->transactionRetry(function () use ( + $merchant, + $merchantId, + $outBizNo, + $type, + $account, + $name, + $amount, + $costAmount, + $totalDebit, + $bizNo, + $traceNo, + $channel, + $input, + &$created + ): TransferOrder { + if ($outBizNo !== '') { + $existing = $this->transferOrderRepository->findForUpdateByOutBizNo($merchantId, $outBizNo); + if ($existing instanceof TransferOrder) { + return $existing; + } + } + + if ($totalDebit > 0) { + $this->merchantAccountService->debitTransferAmountInCurrentTransaction( + $merchantId, + $amount, + $bizNo, + 'TRANSFER_DEDUCT:' . $bizNo, + [ + 'remark' => '转账本金扣减', + 'out_biz_no' => $outBizNo, + ], + $traceNo + ); + + if ($costAmount > 0) { + $this->merchantAccountService->debitTransferFeeInCurrentTransaction( + $merchantId, + $costAmount, + $bizNo, + 'TRANSFER_FEE:' . $bizNo, + [ + 'remark' => '转账手续费扣减', + 'out_biz_no' => $outBizNo, + ], + $traceNo + ); + } + } + + $createdOrder = $this->transferOrderRepository->create([ + 'biz_no' => $bizNo, + 'trace_no' => $traceNo, + 'merchant_id' => $merchantId, + 'merchant_group_id' => (int) ($merchant->group_id ?? 0), + 'out_biz_no' => $outBizNo !== '' ? $outBizNo : $this->generateNo('OBN'), + 'type' => $type, + 'account' => $account, + 'name' => $name, + 'amount' => $amount, + 'cost_amount' => $costAmount, + 'remark' => (string) ($input['remark'] ?? ''), + 'bookid' => (string) ($input['bookid'] ?? ''), + 'channel_id' => (int) $channel->id, + 'channel_request_no' => $this->generateNo('TRQ'), + 'status' => TransferConstant::TRANSFER_STATUS_PROCESSING, + 'request_at' => $this->now(), + 'processing_at' => $this->now(), + 'ext_json' => (array) ($input['ext_json'] ?? []), + ]); + $created = true; + + return $createdOrder; + }); + + if (!$created) { + return $this->formatTransferOrder($this->syncTransferOrderIfNeeded($transferOrder)); + } + + $this->paymentQueueService->sendTransferDispatch((string) $transferOrder->biz_no); return $this->formatTransferOrder($transferOrder); } /** - * 查询转账单。 + * 查询转账单,并在非终态时尝试主动同步一次通道状态。 * * @param Merchant $merchant 商户 - * @param array $input 请求参数 + * @param array $input 请求参数 * @return array */ public function query(Merchant $merchant, array $input): array { $order = $this->resolveTransferOrder($merchant, $input); - return $this->formatTransferOrder($order); + return $this->formatTransferOrder($this->syncTransferOrderIfNeeded($order)); } /** @@ -121,11 +223,346 @@ class TransferService extends BaseService ]; } + /** + * 队列中派发转账到上游通道。 + * + * @param string $bizNo 转账单号 + * @return TransferOrder 最新转账单 + */ + public function dispatchQueuedTransfer(string $bizNo): TransferOrder + { + $order = $this->resolveTransferOrderByBizNo($bizNo); + if (TransferConstant::isTerminalStatus((int) $order->status)) { + return $order; + } + + [$channel, $plugin] = $this->resolveTransferChannelAndPlugin((int) $order->merchant_id, (string) $order->type, (int) $order->channel_id); + unset($channel); + + $latest = $this->dispatchTransfer($order, $plugin); + $this->enqueueNextTransferQueryIfNeeded($latest, 0); + + return $latest; + } + + /** + * 队列中查询转账上游状态。 + * + * @param string $bizNo 转账单号 + * @param int $attempt 当前查单次数 + * @return TransferOrder 最新转账单 + */ + public function queryQueuedTransfer(string $bizNo, int $attempt = 0): TransferOrder + { + $order = $this->resolveTransferOrderByBizNo($bizNo); + if (TransferConstant::isTerminalStatus((int) $order->status)) { + return $order; + } + + $latest = $this->syncTransferOrderIfNeeded($order); + $this->enqueueNextTransferQueryIfNeeded($latest, $attempt); + + return $latest; + } + + /** + * 选择可处理当前转账类型的通道和插件。 + * + * @param int $merchantId 商户ID + * @param string $type 转账类型 + * @param int $channelId 指定通道ID,0 表示自动选择 + * @return array{0: PaymentChannel, 1: TransferPluginInterface} + */ + private function resolveTransferChannelAndPlugin(int $merchantId, string $type, int $channelId = 0): array + { + $query = $this->paymentChannelRepository->query() + ->where('status', CommonConstant::STATUS_ENABLED) + ->whereIn('merchant_id', [0, $merchantId]) + ->orderBy('sort_no') + ->orderBy('id'); + + if ($channelId > 0) { + $query->whereKey($channelId); + } + + $channels = $query->get(); + foreach ($channels as $channel) { + try { + $plugin = $this->paymentPluginManager->createTransferByChannel($channel, false); + } catch (Throwable) { + continue; + } + + if (!$plugin instanceof TransferPluginInterface) { + continue; + } + + $transferTypes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $plugin->getEnabledTransferTypes()))); + if (!in_array($type, $transferTypes, true)) { + continue; + } + + return [$channel, $plugin]; + } + + throw new ValidationException('没有可用的转账通道', [ + 'type' => $type, + 'channel_id' => $channelId, + ]); + } + + /** + * 根据转账单号解析转账单。 + * + * @param string $bizNo 转账单号 + * @return TransferOrder 转账单 + */ + private function resolveTransferOrderByBizNo(string $bizNo): TransferOrder + { + $order = $this->transferOrderRepository->findByBizNo($bizNo); + if (!$order) { + throw new ResourceNotFoundException('转账单不存在', ['biz_no' => $bizNo]); + } + + return $order; + } + + /** + * 请求上游插件发起转账。 + * + * @param TransferOrder $order 转账单 + * @param TransferPluginInterface $plugin 转账插件 + * @return TransferOrder 最新转账单 + */ + private function dispatchTransfer(TransferOrder $order, TransferPluginInterface $plugin): TransferOrder + { + try { + $result = $plugin->transfer($this->buildPluginTransferPayload($order)); + return $this->applyPluginTransferResult($order, $result); + } catch (Throwable $e) { + Log::warning(sprintf( + '[TransferService] 转账请求失败 biz_no=%s error=%s', + (string) $order->biz_no, + $e->getMessage() + )); + + return $this->markTransferFailed($order, $e->getMessage() ?: '转账请求异常'); + } + } + + /** + * 非终态转账单主动查单并同步一次状态。 + * + * @param TransferOrder $order 转账单 + * @return TransferOrder 最新转账单 + */ + private function syncTransferOrderIfNeeded(TransferOrder $order): TransferOrder + { + if (TransferConstant::isTerminalStatus((int) $order->status) || (int) $order->channel_id <= 0) { + return $order; + } + + try { + [$channel, $plugin] = $this->resolveTransferChannelAndPlugin((int) $order->merchant_id, (string) $order->type, (int) $order->channel_id); + unset($channel); + $result = $plugin->transferQuery($this->buildPluginTransferPayload($order)); + return $this->applyPluginTransferResult($order, $result); + } catch (Throwable $e) { + Log::warning(sprintf( + '[TransferService] 转账查单失败 biz_no=%s error=%s', + (string) $order->biz_no, + $e->getMessage() + )); + + return $order; + } + } + + /** + * 如转账仍在处理中,投递下一次延迟查单。 + * + * @param TransferOrder $order 转账单 + * @param int $attempt 当前查单次数 + * @return void + */ + private function enqueueNextTransferQueryIfNeeded(TransferOrder $order, int $attempt): void + { + if (TransferConstant::isTerminalStatus((int) $order->status)) { + return; + } + + if ($attempt >= self::QUERY_MAX_ATTEMPTS) { + $this->markTransferQueryMaxReached($order, $attempt); + return; + } + + $nextAttempt = $attempt + 1; + $delay = $this->resolveTransferQueryDelay($attempt); + $this->paymentQueueService->sendTransferQuery((string) $order->biz_no, $nextAttempt, $delay); + $this->recordTransferQuerySchedule($order, $nextAttempt, $delay, false); + } + + /** + * 应用插件转账结果。 + * + * @param TransferOrder $order 转账单 + * @param array $result 插件结果 + * @return TransferOrder 最新转账单 + */ + private function applyPluginTransferResult(TransferOrder $order, array $result): TransferOrder + { + $status = $this->normalizePluginStatus($result); + if ($status === TransferConstant::TRANSFER_STATUS_FAILED) { + return $this->markTransferFailed($order, (string) ($result['msg'] ?? $result['message'] ?? '转账失败'), $result); + } + + if ($status === TransferConstant::TRANSFER_STATUS_SUCCESS) { + return $this->transactionRetry(function () use ($order, $result): TransferOrder { + $latest = $this->transferOrderRepository->findForUpdateByBizNo((string) $order->biz_no); + if (!$latest || TransferConstant::isTerminalStatus((int) $latest->status)) { + return $latest ?: $order; + } + + $latest->status = TransferConstant::TRANSFER_STATUS_SUCCESS; + $latest->succeeded_at = $result['succeeded_at'] ?? $this->now(); + $latest->channel_order_no = $this->firstText($result, ['channel_order_no', 'chan_order_no', 'orderid']); + $latest->channel_trade_no = $this->firstText($result, ['channel_trade_no', 'chan_trade_no', 'trade_no']); + $latest->channel_error_code = ''; + $latest->channel_error_msg = ''; + $latest->ext_json = array_merge((array) $latest->ext_json, [ + 'plugin_result' => $this->buildPluginResultSnapshot($result), + ]); + $latest->save(); + + return $latest->refresh(); + }); + } + + return $this->transactionRetry(function () use ($order, $result): TransferOrder { + $latest = $this->transferOrderRepository->findForUpdateByBizNo((string) $order->biz_no); + if (!$latest || TransferConstant::isTerminalStatus((int) $latest->status)) { + return $latest ?: $order; + } + + $latest->status = TransferConstant::TRANSFER_STATUS_PROCESSING; + $latest->channel_order_no = $this->firstText($result, ['channel_order_no', 'chan_order_no', 'orderid']) ?: $latest->channel_order_no; + $latest->channel_trade_no = $this->firstText($result, ['channel_trade_no', 'chan_trade_no', 'trade_no']) ?: $latest->channel_trade_no; + $latest->ext_json = array_merge((array) $latest->ext_json, [ + 'plugin_result' => $this->buildPluginResultSnapshot($result), + ]); + $latest->save(); + + return $latest->refresh(); + }); + } + + /** + * 标记转账失败并释放已扣商户余额。 + * + * @param TransferOrder $order 转账单 + * @param string $message 失败原因 + * @param array $result 插件结果 + * @return TransferOrder 最新转账单 + */ + private function markTransferFailed(TransferOrder $order, string $message, array $result = []): TransferOrder + { + return $this->transactionRetry(function () use ($order, $message, $result): TransferOrder { + $latest = $this->transferOrderRepository->findForUpdateByBizNo((string) $order->biz_no); + if (!$latest || TransferConstant::isTerminalStatus((int) $latest->status)) { + return $latest ?: $order; + } + + $releaseAmount = (int) $latest->amount + (int) $latest->cost_amount; + if ($releaseAmount > 0) { + $this->merchantAccountService->releaseTransferAmountInCurrentTransaction( + (int) $latest->merchant_id, + $releaseAmount, + (string) $latest->biz_no, + 'TRANSFER_RELEASE:' . (string) $latest->biz_no, + [ + 'remark' => '转账失败释放', + 'message' => $message, + ], + (string) ($latest->trace_no ?: $latest->biz_no) + ); + } + + $latest->status = TransferConstant::TRANSFER_STATUS_FAILED; + $latest->failed_at = $this->now(); + $latest->channel_error_code = $this->firstText($result, ['channel_error_code', 'code']); + $latest->channel_error_msg = $message; + $latest->ext_json = array_merge((array) $latest->ext_json, [ + 'plugin_result' => $this->buildPluginResultSnapshot($result), + ]); + $latest->save(); + + return $latest->refresh(); + }); + } + + /** + * 记录转账查单已达到自动查询上限。 + * + * @param TransferOrder $order 转账单 + * @param int $attempt 当前查单次数 + * @return void + */ + private function markTransferQueryMaxReached(TransferOrder $order, int $attempt): void + { + $this->recordTransferQuerySchedule($order, $attempt, 0, true); + Log::warning(sprintf( + '[TransferService] 转账自动查单达到上限 biz_no=%s attempt=%s', + (string) $order->biz_no, + $attempt + )); + } + + /** + * 记录自动查单调度快照。 + * + * @param TransferOrder $order 转账单 + * @param int $attempt 下一次或当前查单次数 + * @param int $delay 延迟秒数 + * @param bool $maxReached 是否达到上限 + * @return void + */ + private function recordTransferQuerySchedule(TransferOrder $order, int $attempt, int $delay, bool $maxReached): void + { + try { + $this->transactionRetry(function () use ($order, $attempt, $delay, $maxReached): void { + $latest = $this->transferOrderRepository->findForUpdateByBizNo((string) $order->biz_no); + if (!$latest || TransferConstant::isTerminalStatus((int) $latest->status)) { + return; + } + + $runtime = (array) (((array) ($latest->ext_json ?? []))['runtime'] ?? []); + $runtime['transfer_query'] = [ + 'attempt' => $attempt, + 'max_attempts' => self::QUERY_MAX_ATTEMPTS, + 'last_scheduled_at' => $this->now(), + 'next_delay_seconds' => $delay, + 'max_reached' => $maxReached, + ]; + + $extJson = (array) ($latest->ext_json ?? []); + $extJson['runtime'] = $runtime; + $latest->ext_json = $extJson; + $latest->save(); + }); + } catch (Throwable $e) { + Log::warning(sprintf( + '[TransferService] 记录转账查单调度失败 biz_no=%s error=%s', + (string) $order->biz_no, + $e->getMessage() + )); + } + } + /** * 解析转账单。 * * @param Merchant $merchant 商户 - * @param array $input 请求参数 + * @param array $input 请求参数 * @return TransferOrder */ private function resolveTransferOrder(Merchant $merchant, array $input): TransferOrder @@ -168,7 +605,7 @@ class TransferService extends BaseService 'errmsg' => (string) ($order->channel_error_msg ?? ''), 'biz_no' => (string) $order->biz_no, 'out_biz_no' => (string) $order->out_biz_no, - 'orderid' => (string) $order->biz_no, + 'orderid' => (string) ($order->channel_order_no ?: $order->biz_no), 'paydate' => $this->formatDateTime($order->succeeded_at ?? null, ''), 'amount' => $this->formatAmount((int) $order->amount), 'cost_money' => $this->formatAmount((int) $order->cost_amount), @@ -176,10 +613,103 @@ class TransferService extends BaseService ]; } + /** + * 构建插件转账请求载荷。 + * + * @param TransferOrder $order 转账单 + * @return array 插件转账参数 + */ + private function buildPluginTransferPayload(TransferOrder $order): array + { + return [ + 'transfer_no' => (string) $order->biz_no, + 'biz_no' => (string) $order->biz_no, + 'out_biz_no' => (string) $order->out_biz_no, + 'type' => (string) $order->type, + 'account' => (string) $order->account, + 'name' => (string) $order->name, + 'amount' => (int) $order->amount, + 'money' => $this->formatAmount((int) $order->amount), + 'cost_amount' => (int) $order->cost_amount, + 'remark' => (string) $order->remark, + 'bookid' => (string) $order->bookid, + 'channel_request_no' => (string) $order->channel_request_no, + 'channel_order_no' => (string) ($order->channel_order_no ?? ''), + 'channel_trade_no' => (string) ($order->channel_trade_no ?? ''), + 'trace_no' => (string) ($order->trace_no ?: $order->biz_no), + 'extra' => (array) ($order->ext_json ?? []), + ]; + } + + /** + * 归一化插件转账状态。 + * + * @param array $result 插件结果 + * @return int 平台转账状态 + */ + private function normalizePluginStatus(array $result): int + { + $statusText = strtolower(trim((string) ($result['status'] ?? $result['trade_status'] ?? $result['channel_status'] ?? ''))); + if (in_array($statusText, ['success', 'succeeded', 'finished', 'paid'], true)) { + return TransferConstant::TRANSFER_STATUS_SUCCESS; + } + + if (in_array($statusText, ['failed', 'fail', 'closed', 'error'], true)) { + return TransferConstant::TRANSFER_STATUS_FAILED; + } + + if (array_key_exists('success', $result) && (bool) $result['success'] === false) { + return TransferConstant::TRANSFER_STATUS_FAILED; + } + + $statusCode = (int) ($result['status_code'] ?? -1); + if ($statusCode === TransferConstant::TRANSFER_STATUS_SUCCESS) { + return TransferConstant::TRANSFER_STATUS_SUCCESS; + } + if ($statusCode === TransferConstant::TRANSFER_STATUS_FAILED) { + return TransferConstant::TRANSFER_STATUS_FAILED; + } + + return TransferConstant::TRANSFER_STATUS_PROCESSING; + } + + /** + * 构建插件结果快照。 + * + * raw_data 可能包含完整上游响应,快照落库时剔除以降低日志和数据表压力。 + * + * @param array $result 插件结果 + * @return array 可落库结果 + */ + private function buildPluginResultSnapshot(array $result): array + { + unset($result['raw_data']); + return $result; + } + + /** + * 从多个候选字段中取第一个非空文本。 + * + * @param array $data 数据 + * @param array $keys 候选字段 + * @return string 字段值 + */ + private function firstText(array $data, array $keys): string + { + foreach ($keys as $key) { + $value = $data[$key] ?? null; + if (is_scalar($value) && trim((string) $value) !== '') { + return trim((string) $value); + } + } + + return ''; + } + /** * 解析转账费率。 * - * @return float + * @return float 转账手续费率 */ private function resolveTransferRate(): float { @@ -193,6 +723,19 @@ class TransferService extends BaseService return $floatRate > 0 ? $floatRate : 0.01; } + /** + * 解析下一次转账查单延迟。 + * + * @param int $attempt 当前查单次数 + * @return int 延迟秒数 + */ + private function resolveTransferQueryDelay(int $attempt): int + { + $index = max(0, min($attempt, count(self::QUERY_DELAY_SECONDS) - 1)); + + return self::QUERY_DELAY_SECONDS[$index]; + } + /** * 金额字符串转分。 * diff --git a/app/service/system/config/SystemConfigDefinitionService.php b/app/service/system/config/SystemConfigDefinitionService.php index b2b93d4..6258f70 100644 --- a/app/service/system/config/SystemConfigDefinitionService.php +++ b/app/service/system/config/SystemConfigDefinitionService.php @@ -4,6 +4,7 @@ namespace app\service\system\config; use app\common\base\BaseService; use app\exception\ConflictException; +use app\exception\ValidationException; /** * 系统配置定义解析服务。 @@ -119,6 +120,92 @@ class SystemConfigDefinitionService extends BaseService return $this->tabMapCache[$groupCode] ?? null; } + /** + * 获取标签页内的实际配置字段。 + * + * @param array $tab 标签页定义 + * @return array 配置字段列表 + */ + public function fields(array $tab): array + { + $fields = []; + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field === '' || $this->isVirtualField($field)) { + continue; + } + + $fields[$field] = true; + } + + return array_keys($fields); + } + + /** + * 获取全部实际配置字段。 + * + * @return array 配置字段列表 + */ + public function allFields(): array + { + $fields = []; + foreach ($this->tabs() as $tab) { + foreach ($this->fields($tab) as $field) { + $fields[$field] = true; + } + } + + return array_keys($fields); + } + + /** + * 获取标签页内配置项的默认落库值。 + * + * @param array $tab 标签页定义 + * @return array 字段到默认值的映射 + * @throws ValidationException + */ + public function defaultStorageValues(array $tab): array + { + $defaults = []; + foreach ((array) ($tab['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + + $field = strtolower(trim((string) ($rule['field'] ?? ''))); + if ($field === '' || $this->isVirtualField($field)) { + continue; + } + + $defaults[$field] = $this->stringifyValue($rule['value'] ?? ''); + } + + return $defaults; + } + + /** + * 获取全部配置项默认落库值。 + * + * @return array 字段到默认值的映射 + * @throws ValidationException + */ + public function allDefaultStorageValues(): array + { + $defaults = []; + foreach ($this->tabs() as $tab) { + foreach ($this->defaultStorageValues($tab) as $field => $value) { + $defaults[$field] = $value; + } + } + + return $defaults; + } + /** * 使用当前值回填标签页规则。 * @@ -140,7 +227,10 @@ class SystemConfigDefinitionService extends BaseService } if (!$this->isVirtualField($field)) { - $rule['value'] = array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? ''); + $rule['value'] = $this->normalizeValueForForm( + $rule, + array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? '') + ); } $rules[] = $rule; } @@ -168,7 +258,10 @@ class SystemConfigDefinitionService extends BaseService continue; } - $data[$field] = array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? ''); + $data[$field] = $this->normalizeValueForForm( + $rule, + array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? '') + ); } return $data; @@ -208,6 +301,26 @@ class SystemConfigDefinitionService extends BaseService return $messages; } + /** + * 将配置值转换为可落库字符串。 + * + * @param mixed $value 配置值 + * @return string 可落库字符串 + * @throws ValidationException + */ + public function stringifyValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_array($value) || is_object($value)) { + throw new ValidationException('系统配置值暂不支持复杂类型'); + } + + return (string) $value; + } + /** * 标准化单个标签页定义。 * @@ -268,7 +381,7 @@ class SystemConfigDefinitionService extends BaseService $options[] = [ 'label' => (string) ($option['label'] ?? ''), - 'value' => (string) ($option['value'] ?? ''), + 'value' => $option['value'] ?? '', ]; } @@ -285,7 +398,7 @@ class SystemConfigDefinitionService extends BaseService $normalized['type'] = (string) ($rule['type'] ?? 'input'); $normalized['field'] = $field; $normalized['title'] = (string) ($rule['title'] ?? $field); - $normalized['value'] = (string) ($rule['value'] ?? ''); + $normalized['value'] = $rule['value'] ?? ''; $normalized['props'] = is_array($rule['props'] ?? null) ? $rule['props'] : []; $normalized['options'] = $options; $normalized['validate'] = $validate; @@ -293,14 +406,39 @@ class SystemConfigDefinitionService extends BaseService return $normalized; } + /** + * 按表单组件类型整理前端模型值。 + * + * @param array $rule 配置项定义 + * @param mixed $value 原始值 + * @return mixed 前端表单值 + */ + private function normalizeValueForForm(array $rule, mixed $value): mixed + { + $type = strtolower(trim((string) ($rule['type'] ?? ''))); + if ($type !== 'inputnumber') { + return $value; + } + + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return str_contains((string) $value, '.') ? (float) $value : (int) $value; + } + + return null; + } + /** * 判断是否为虚拟字段。 * * @param string $field 字段名 * @return bool 是否为虚拟字段 */ - private function isVirtualField(string $field): bool + public function isVirtualField(string $field): bool { - return str_starts_with($field, self::VIRTUAL_FIELD_PREFIX); + return str_starts_with(trim($field), self::VIRTUAL_FIELD_PREFIX); } } diff --git a/app/service/system/config/SystemConfigPageService.php b/app/service/system/config/SystemConfigPageService.php index f3a0ea4..176fd4a 100644 --- a/app/service/system/config/SystemConfigPageService.php +++ b/app/service/system/config/SystemConfigPageService.php @@ -76,31 +76,9 @@ class SystemConfigPageService extends BaseService throw new ValidationException('系统配置标签不存在'); } - $keys = []; - foreach ((array) ($tab['rules'] ?? []) as $rule) { - if (!is_array($rule)) { - continue; - } - - $field = strtolower(trim((string) ($rule['field'] ?? ''))); - if ($field !== '' && !str_starts_with($field, '__')) { - $keys[] = $field; - } - } - - $keys = array_values(array_unique($keys)); - if ($keys === []) { - $rowMap = []; - } else { - $rows = $this->systemConfigRepository->query() - ->whereIn('config_key', $keys) - ->get(['config_key', 'config_value']); - - $rowMap = []; - foreach ($rows as $row) { - $rowMap[strtolower((string) $row->config_key)] = (string) ($row->config_value ?? ''); - } - } + $rowMap = $this->systemConfigRepository->valueMapByKeys( + $this->systemConfigDefinitionService->fields($tab) + ); $tab['rules'] = $this->systemConfigDefinitionService->hydrateRules($tab, $rowMap); $tab['formData'] = $this->systemConfigDefinitionService->extractFormData($tab, $rowMap); @@ -127,17 +105,8 @@ class SystemConfigPageService extends BaseService $this->validateRequiredValues($tab, $formData); $this->transaction(function () use ($tab, $formData): void { - foreach ((array) ($tab['rules'] ?? []) as $rule) { - if (!is_array($rule)) { - continue; - } - - $field = strtolower(trim((string) ($rule['field'] ?? ''))); - if ($field === '' || str_starts_with($field, '__')) { - continue; - } - - $value = $this->stringifyValue($formData[$field] ?? ''); + foreach ($this->systemConfigDefinitionService->fields($tab) as $field) { + $value = $this->systemConfigDefinitionService->stringifyValue($formData[$field] ?? ''); $this->systemConfigRepository->updateOrCreate( ['config_key' => $field], [ @@ -194,23 +163,4 @@ class SystemConfigPageService extends BaseService return $value === null || $value === ''; } - /** - * 将配置值转换为可保存字符串。 - * - * @param array|object|bool|float|int|string|null $value 配置值 - * @return string 可保存字符串 - * @throws ValidationException - */ - protected function stringifyValue(array|object|bool|float|int|string|null $value): string - { - if (is_bool($value)) { - return $value ? '1' : '0'; - } - - if (is_array($value) || is_object($value)) { - throw new ValidationException('系统配置值暂不支持复杂类型'); - } - - return (string) $value; - } } diff --git a/app/service/system/config/SystemConfigRuntimeService.php b/app/service/system/config/SystemConfigRuntimeService.php index 294660d..a189008 100644 --- a/app/service/system/config/SystemConfigRuntimeService.php +++ b/app/service/system/config/SystemConfigRuntimeService.php @@ -90,52 +90,13 @@ class SystemConfigRuntimeService extends BaseService */ protected function buildValueMap(): array { - $values = []; - $tabs = $this->systemConfigDefinitionService->tabs(); - $keys = []; - - foreach ($tabs as $tab) { - foreach ((array) ($tab['rules'] ?? []) as $rule) { - if (!is_array($rule)) { - continue; - } - - $field = strtolower(trim((string) ($rule['field'] ?? ''))); - if ($field !== '' && !str_starts_with($field, '__')) { - $keys[] = $field; - } - } - } - - $keys = array_values(array_unique($keys)); - if ($keys === []) { + $values = $this->systemConfigDefinitionService->allDefaultStorageValues(); + if ($values === []) { return []; } - $rows = $this->systemConfigRepository->query() - ->whereIn('config_key', $keys) - ->get(['config_key', 'config_value']); - - $rowMap = []; - foreach ($rows as $row) { - $rowMap[strtolower((string) $row->config_key)] = (string) ($row->config_value ?? ''); - } - - foreach ($tabs as $tab) { - foreach ((array) ($tab['rules'] ?? []) as $rule) { - if (!is_array($rule)) { - continue; - } - - $field = strtolower(trim((string) ($rule['field'] ?? ''))); - if ($field === '' || str_starts_with($field, '__')) { - continue; - } - - $values[$field] = array_key_exists($field, $rowMap) - ? (string) $rowMap[$field] - : (string) ($rule['value'] ?? ''); - } + foreach ($this->systemConfigRepository->valueMapByKeys(array_keys($values)) as $field => $value) { + $values[$field] = $value; } return $values; diff --git a/app/service/system/config/SystemPublicConfigService.php b/app/service/system/config/SystemPublicConfigService.php new file mode 100644 index 0000000..b0f7363 --- /dev/null +++ b/app/service/system/config/SystemPublicConfigService.php @@ -0,0 +1,135 @@ + + */ + public function adminPortal(): array + { + return $this->portalConfig('admin_portal_name', '支付中台管理后台'); + } + + /** + * 商户后台展示配置。 + * + * @return array + */ + public function merchantPortal(): array + { + return array_replace($this->portalConfig('merchant_portal_name', '支付中台商户后台'), [ + 'merchant_announcement_enabled' => $this->boolConfig('merchant_announcement_enabled', false), + 'merchant_announcement' => $this->textConfig('merchant_announcement'), + ]); + } + + /** + * 收银台展示配置。 + * + * @return array + */ + public function cashier(): array + { + $siteName = $this->textConfig('site_name', 'MPAY 支付中台'); + $siteLogo = $this->textConfig('site_logo', self::DEFAULT_SITE_LOGO); + $cashierLogo = $this->textConfig('cashier_logo'); + + return [ + 'enabled' => $this->boolConfig('cashier_enabled', true), + 'site_name' => $siteName, + 'title' => $this->textConfig('cashier_title', 'MPAY 收银台'), + 'logo' => $cashierLogo !== '' ? $cashierLogo : $siteLogo, + 'notice_enabled' => $this->boolConfig('cashier_notice_enabled', true), + 'notice' => $this->textConfig('cashier_notice', '确认支付方式后,系统会创建本次支付尝试并跳转支付页。'), + 'show_merchant_name' => $this->boolConfig('cashier_show_merchant_name', true), + 'show_order_no' => $this->boolConfig('cashier_show_order_no', true), + 'show_pay_type_desc' => $this->boolConfig('cashier_show_pay_type_desc', true), + 'poll_interval_seconds' => $this->intConfig('cashier_poll_interval_seconds', 2, 1, 60), + 'poll_timeout_seconds' => $this->intConfig('cashier_poll_timeout_seconds', 300, 30, 3600), + 'customer_service_enabled' => $this->boolConfig('customer_service_enabled', false), + 'customer_service_name' => $this->textConfig('customer_service_name'), + 'customer_service_phone' => $this->textConfig('customer_service_phone'), + 'customer_service_email' => $this->textConfig('customer_service_email'), + ]; + } + + /** + * 按端整理门户通用展示配置。 + * + * @param string $portalNameKey 门户名称配置 key + * @param string $portalNameDefault 门户名称默认值 + * @return array + */ + private function portalConfig(string $portalNameKey, string $portalNameDefault): array + { + return [ + 'site_name' => $this->textConfig('site_name', 'MPAY 支付中台'), + 'site_url' => rtrim($this->textConfig('site_url'), '/'), + 'site_logo' => $this->textConfig('site_logo', self::DEFAULT_SITE_LOGO), + 'site_logo_compact' => $this->textConfig('site_logo_compact', self::DEFAULT_SITE_LOGO_COMPACT), + 'portal_name' => $this->textConfig($portalNameKey, $portalNameDefault), + 'customer_service_enabled' => $this->boolConfig('customer_service_enabled', false), + 'customer_service_name' => $this->textConfig('customer_service_name'), + 'customer_service_phone' => $this->textConfig('customer_service_phone'), + 'customer_service_email' => $this->textConfig('customer_service_email'), + ]; + } + + /** + * 读取文本配置。 + * + * @param string $key 配置键 + * @param string $default 默认值 + * @return string 文本值 + */ + private function textConfig(string $key, string $default = ''): string + { + $value = trim((string) sys_config($key, $default)); + + return $value !== '' ? $value : $default; + } + + /** + * 读取布尔配置。 + * + * @param string $key 配置键 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function boolConfig(string $key, bool $default): bool + { + $value = strtolower(trim((string) sys_config($key, $default ? '1' : '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } + + /** + * 读取整数配置。 + * + * @param string $key 配置键 + * @param int $default 默认值 + * @param int $min 最小值 + * @param int $max 最大值 + * @return int 整数值 + */ + private function intConfig(string $key, int $default, int $min, int $max): int + { + $value = (int) sys_config($key, $default); + + return min($max, max($min, $value)); + } +}