diff --git a/app/command/EpayMapiTest.php b/app/command/EpayMapiTest.php index 498b836..4608481 100644 --- a/app/command/EpayMapiTest.php +++ b/app/command/EpayMapiTest.php @@ -2,10 +2,12 @@ namespace app\command; +use app\common\constant\AuthConstant; +use app\common\constant\CommonConstant; use app\common\constant\RouteConstant; use app\common\constant\TradeConstant; use app\common\util\FormatHelper; -use app\http\api\controller\adapter\EpayController; +use app\http\api\controller\epay\EpayV1Controller; use app\model\merchant\Merchant; use app\model\merchant\MerchantApiCredential; use app\model\payment\PaymentChannel; @@ -20,7 +22,7 @@ use app\repository\payment\config\PaymentPollGroupRepository; use app\repository\payment\config\PaymentTypeRepository; use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; -use RuntimeException; +use app\exception\CommandException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -46,11 +48,15 @@ class EpayMapiTest extends Command $this ->setDescription('自动读取真实商户、路由和插件配置,测试 ePay mapi 是否能正常调用并返回可用结果。') ->addOption('live', null, InputOption::VALUE_NONE, '使用真实数据库并发起实际 mapi 调用') + ->addOption('skip-api', null, InputOption::VALUE_NONE, '跳过 mapi 成功后的 V1 api.php 查询接口校验') ->addOption('merchant-id', null, InputOption::VALUE_OPTIONAL, '指定商户 ID') ->addOption('merchant-no', null, InputOption::VALUE_OPTIONAL, '指定商户号') ->addOption('type', null, InputOption::VALUE_OPTIONAL, '支付方式编码,默认 alipay', 'alipay') ->addOption('money', null, InputOption::VALUE_OPTIONAL, '支付金额,单位元,默认 1.00', '1.00') ->addOption('device', null, InputOption::VALUE_OPTIONAL, '设备类型,默认 pc', 'pc') + ->addOption('refund-trade-no', null, InputOption::VALUE_OPTIONAL, '指定需要退款的 V1 平台订单号') + ->addOption('refund-out-trade-no', null, InputOption::VALUE_OPTIONAL, '指定需要退款的商户订单号') + ->addOption('refund-money', null, InputOption::VALUE_OPTIONAL, '退款金额,单位元') ->addOption('out-trade-no', null, InputOption::VALUE_OPTIONAL, '商户订单号,默认自动生成'); } @@ -116,8 +122,8 @@ class EpayMapiTest extends Command $device, $siteUrl ); - /** @var EpayController $controller */ - $controller = $this->resolve(EpayController::class); + /** @var EpayV1Controller $controller */ + $controller = $this->resolve(EpayV1Controller::class); $response = $controller->mapi($this->buildRequest($payload)); $responseData = $this->decodeResponse($response->rawBody()); $orderSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $outTradeNo); @@ -125,7 +131,29 @@ class EpayMapiTest extends Command $this->writeAttempt($output, $payload, $responseData, $orderSnapshot); $status = $this->classifyAttempt($responseData, $orderSnapshot); - return $status === 'pass' ? self::SUCCESS : self::FAILURE; + if ($status !== 'pass') { + return self::FAILURE; + } + + if ($this->optionBool($input, 'skip-api', false)) { + return self::SUCCESS; + } + + $apiPassed = $this->runCompatibleApiChecks( + $output, + $merchant, + $credential, + $outTradeNo, + (string) ($responseData['trade_no'] ?? '') + ); + + if (!$apiPassed) { + return self::FAILURE; + } + + $refundPassed = $this->runOptionalRefundCheck($input, $output, $merchant, $credential); + + return $refundPassed ? self::SUCCESS : self::FAILURE; } catch (\Throwable $e) { $output->writeln('[失败] ' . $this->formatThrowable($e)); @@ -140,7 +168,7 @@ class EpayMapiTest extends Command */ private function ensureDependencies(): void { - $this->resolve(EpayController::class); + $this->resolve(EpayV1Controller::class); $this->resolve(MerchantRepository::class); $this->resolve(MerchantApiCredentialRepository::class); $this->resolve(PaymentTypeRepository::class); @@ -159,26 +187,26 @@ class EpayMapiTest extends Command * @param string $merchantNoOption 商户编号选项 * @param string $typeCode 支付方式编码 * @return array 上下文数据 - * @throws RuntimeException + * @throws CommandException */ private function discoverContext(int $merchantIdOption, string $merchantNoOption, string $typeCode): array { /** @var PaymentTypeRepository $paymentTypeRepository */ $paymentTypeRepository = $this->resolve(PaymentTypeRepository::class); $paymentType = $paymentTypeRepository->findByCode($typeCode); - if (!$paymentType || (int) $paymentType->status !== 1) { - throw new RuntimeException('未找到可用的支付方式: ' . $typeCode); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('未找到可用的支付方式: ' . $typeCode); } $merchant = $this->pickMerchant($merchantIdOption, $merchantNoOption); $credential = $this->findMerchantCredential((int) $merchant->id); if (!$credential) { - throw new RuntimeException('商户未开通有效 API 凭证: ' . $merchant->merchant_no); + throw new CommandException('商户未开通有效 API 凭证: ' . $merchant->merchant_no); } $route = $this->buildRouteSnapshot((int) $merchant->group_id, (int) $paymentType->id); if ($route === null) { - throw new RuntimeException('商户未配置可用路由: ' . $merchant->merchant_no); + throw new CommandException('商户未配置可用路由: ' . $merchant->merchant_no); } return [ @@ -195,7 +223,7 @@ class EpayMapiTest extends Command * @param int $merchantIdOption 商户 ID 选项 * @param string $merchantNoOption 商户编号选项 * @return Merchant 商户记录 - * @throws RuntimeException + * @throws CommandException */ private function pickMerchant(int $merchantIdOption, string $merchantNoOption): Merchant { @@ -204,12 +232,12 @@ class EpayMapiTest extends Command if ($merchantIdOption > 0) { $merchant = $merchantRepository->find($merchantIdOption); - if (!$merchant || (int) $merchant->status !== 1) { - throw new RuntimeException('指定商户不存在或未启用: ' . $merchantIdOption); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('指定商户不存在或未启用: ' . $merchantIdOption); } if ($merchantNoOption !== '' && (string) $merchant->merchant_no !== $merchantNoOption) { - throw new RuntimeException('merchant-id 和 merchant-no 不匹配。'); + throw new CommandException('商户ID与商户号不匹配。'); } return $merchant; @@ -217,8 +245,8 @@ class EpayMapiTest extends Command if ($merchantNoOption !== '') { $merchant = $merchantRepository->findByMerchantNo($merchantNoOption); - if (!$merchant || (int) $merchant->status !== 1) { - throw new RuntimeException('指定商户不存在或未启用: ' . $merchantNoOption); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('指定商户不存在或未启用: ' . $merchantNoOption); } return $merchant; @@ -226,7 +254,7 @@ class EpayMapiTest extends Command $merchant = $merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name', 'group_id', 'status'])->first(); if (!$merchant) { - throw new RuntimeException('未找到启用中的真实商户。'); + throw new CommandException('未找到启用中的真实商户。'); } return $merchant; @@ -243,7 +271,7 @@ class EpayMapiTest extends Command /** @var MerchantApiCredentialRepository $repository */ $repository = $this->resolve(MerchantApiCredentialRepository::class); $credential = $repository->findByMerchantId($merchantId); - if (!$credential || (int) $credential->status !== 1) { + if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) { return null; } @@ -274,7 +302,7 @@ class EpayMapiTest extends Command } $pollGroup = $pollGroupRepository->find((int) $bind->poll_group_id); - if (!$pollGroup || (int) $pollGroup->status !== 1) { + if (!$pollGroup || (int) $pollGroup->status !== CommonConstant::STATUS_ENABLED) { return null; } @@ -351,7 +379,7 @@ class EpayMapiTest extends Command 'money' => $money, 'clientip' => '127.0.0.1', 'device' => $device, - 'sign_type' => 'MD5', + 'sign_type' => AuthConstant::API_SIGN_NAME_MD5, ]; $payload['sign'] = $this->signPayload($payload, (string) $credential->api_key); @@ -452,7 +480,7 @@ class EpayMapiTest extends Command (string) ($responseData['code'] ?? ''), (string) ($responseData['msg'] ?? '') )); - foreach (['trade_no', 'payurl', 'origin_payurl', 'qrcode', 'urlscheme'] as $key) { + foreach (['trade_no', 'payurl', 'qrcode', 'urlscheme'] as $key) { if (!isset($responseData[$key]) || $responseData[$key] === '') { continue; } @@ -493,7 +521,8 @@ class EpayMapiTest extends Command )); $extJson = (array) ($payOrder['ext_json'] ?? []); - $summary = $this->summarizePayParamsSnapshot((array) ($extJson['pay_params_snapshot'] ?? [])); + $presentation = (array) ($extJson['presentation'] ?? []); + $summary = $this->summarizePayParamsSnapshot((array) ($presentation['params_snapshot'] ?? [])); if ($summary !== []) { $output->writeln(' 插件返回:'); $output->writeln(' ' . $this->formatJson($summary)); @@ -534,7 +563,6 @@ class EpayMapiTest extends Command break; case 'url': $summary['payurl'] = $this->stringifyValue($snapshot['payurl'] ?? ''); - $summary['origin_payurl'] = $this->stringifyValue($snapshot['origin_payurl'] ?? ''); break; default: if (isset($snapshot['raw']) && is_array($snapshot['raw'])) { @@ -627,16 +655,17 @@ class EpayMapiTest extends Command * @param array $payload 请求载荷 * @return Request 请求对象 */ - private function buildRequest(array $payload): Request + private function buildRequest(array $payload, string $path = '/mapi.php'): Request { $body = http_build_query($payload, '', '&', PHP_QUERY_RFC1738); $siteUrl = $this->resolveSiteUrl(); $host = parse_url($siteUrl, PHP_URL_HOST) ?: 'localhost'; $port = parse_url($siteUrl, PHP_URL_PORT); $hostHeader = $port ? sprintf('%s:%s', $host, $port) : $host; + $path = '/' . ltrim($path, '/'); $rawRequest = implode("\r\n", [ - 'POST /mapi.php HTTP/1.1', + 'POST ' . $path . ' HTTP/1.1', 'Host: ' . $hostHeader, 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', 'Content-Length: ' . strlen($body), @@ -679,6 +708,182 @@ class EpayMapiTest extends Command ]; } + /** + * 在 mapi 成功后继续校验 V1 api.php 查询接口。 + * + * @param OutputInterface $output 输出对象 + * @param Merchant $merchant 商户 + * @param MerchantApiCredential $credential 商户 API 凭证 + * @param string $merchantOrderNo 商户订单号 + * @param string $tradeNo 平台订单号 + * @return bool 是否全部通过 + */ + private function runCompatibleApiChecks( + OutputInterface $output, + Merchant $merchant, + MerchantApiCredential $credential, + string $merchantOrderNo, + string $tradeNo + ): bool { + $checks = [ + 'query' => [ + 'payload' => [ + 'act' => 'query', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + ], + 'assert' => function (array $responseData) use ($merchant, $credential): bool { + return (int) ($responseData['code'] ?? 0) === 1 + && (int) ($responseData['pid'] ?? 0) === (int) $merchant->id + && (string) ($responseData['key'] ?? '') === (string) $credential->api_key; + }, + ], + 'order(out_trade_no)' => [ + 'payload' => [ + 'act' => 'order', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'out_trade_no' => $merchantOrderNo, + ], + 'assert' => function (array $responseData) use ($merchantOrderNo): bool { + return (int) ($responseData['code'] ?? 0) === 1 + && (string) ($responseData['out_trade_no'] ?? '') === $merchantOrderNo; + }, + ], + 'orders' => [ + 'payload' => [ + 'act' => 'orders', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'limit' => 5, + 'page' => 1, + ], + 'assert' => static function (array $responseData): bool { + return (int) ($responseData['code'] ?? 0) === 1 && is_array($responseData['data'] ?? null); + }, + ], + ]; + + if ($tradeNo !== '') { + $checks['order(trade_no)'] = [ + 'payload' => [ + 'act' => 'order', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'trade_no' => $tradeNo, + ], + 'assert' => function (array $responseData) use ($tradeNo): bool { + return (int) ($responseData['code'] ?? 0) === 1 + && (string) ($responseData['trade_no'] ?? '') === $tradeNo; + }, + ]; + } + + $allPassed = true; + foreach ($checks as $name => $check) { + $responseData = $this->callCompatibleApi($check['payload']); + $passed = (bool) ($check['assert'])($responseData); + $allPassed = $allPassed && $passed; + $this->writeCompatibleApiAttempt($output, $name, $responseData, $passed); + } + + return $allPassed; + } + + /** + * 根据命令行参数决定是否追加 V1 退款接口校验。 + * + * @param InputInterface $input 命令输入 + * @param OutputInterface $output 输出对象 + * @param Merchant $merchant 商户 + * @param MerchantApiCredential $credential 商户 API 凭证 + * @return bool 是否通过 + * @throws CommandException + */ + private function runOptionalRefundCheck( + InputInterface $input, + OutputInterface $output, + Merchant $merchant, + MerchantApiCredential $credential + ): bool { + $tradeNo = trim($this->optionString($input, 'refund-trade-no', '')); + $merchantOrderNo = trim($this->optionString($input, 'refund-out-trade-no', '')); + if ($tradeNo === '' && $merchantOrderNo === '') { + return true; + } + + $payload = [ + 'act' => 'refund', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'money' => $this->normalizeMoney($this->optionString($input, 'refund-money', '1.00')), + ]; + if ($tradeNo !== '') { + $payload['trade_no'] = $tradeNo; + } + if ($merchantOrderNo !== '') { + $payload['out_trade_no'] = $merchantOrderNo; + } + + $responseData = $this->callCompatibleApi($payload); + $passed = (int) ($responseData['code'] ?? 0) === 1; + $this->writeCompatibleApiAttempt($output, 'refund', $responseData, $passed); + + return $passed; + } + + /** + * 调用 V1 api.php 接口。 + * + * @param array $payload 请求载荷 + * @return array 响应数据 + */ + private function callCompatibleApi(array $payload): array + { + /** @var EpayV1Controller $controller */ + $controller = $this->resolve(EpayV1Controller::class); + $response = $controller->api($this->buildRequest($payload, '/api.php')); + + return $this->decodeResponse($response->rawBody()); + } + + /** + * 输出 V1 api.php 接口校验结果。 + * + * @param OutputInterface $output 输出对象 + * @param string $name 检查名称 + * @param array $responseData 响应数据 + * @param bool $passed 是否通过 + * @return void + */ + private function writeCompatibleApiAttempt( + OutputInterface $output, + string $name, + array $responseData, + bool $passed + ): void { + $label = $passed ? '[通过]' : '[失败]'; + $output->writeln(sprintf( + '%s api(%s) - code=%s msg=%s', + $label, + $name, + (string) ($responseData['code'] ?? ''), + (string) ($responseData['msg'] ?? '') + )); + + foreach (['pid', 'trade_no', 'out_trade_no', 'orders', 'order_today', 'order_lastday'] as $key) { + if (!array_key_exists($key, $responseData)) { + continue; + } + + $output->writeln(sprintf(' 返回: %s=%s', $key, $this->stringifyValue($responseData[$key]))); + } + + if (isset($responseData['data']) && is_array($responseData['data'])) { + $output->writeln(sprintf(' 数据条数: %d', count($responseData['data']))); + } + } + /** * 解析站点地址。 * @@ -695,7 +900,7 @@ class EpayMapiTest extends Command * * @param string $money 金额 * @return string 金额字符串 - * @throws RuntimeException + * @throws CommandException */ private function normalizeMoney(string $money): string { @@ -705,7 +910,7 @@ class EpayMapiTest extends Command } if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { - throw new RuntimeException('money 参数不合法: ' . $money); + throw new CommandException('money 参数不合法: ' . $money); } return number_format((float) $money, 2, '.', ''); @@ -809,8 +1014,7 @@ class EpayMapiTest extends Command { $data = method_exists($e, 'getData') ? $e->getData() : []; $suffix = is_array($data) && $data !== [] ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; - - return $e::class . ': ' . $e->getMessage() . $suffix; + return $e::class . ':' . $e->getMessage() . $suffix; } /** @@ -866,18 +1070,18 @@ class EpayMapiTest extends Command * * @param string $class 类名 * @return object 对象实例 - * @throws RuntimeException + * @throws CommandException */ private function resolve(string $class): object { try { $instance = container_make($class, []); } catch (\Throwable $e) { - throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e); + throw new CommandException("无法解析 {$class}。", 0, $e); } if (!is_object($instance)) { - throw new RuntimeException("解析后的 {$class} 不是对象。"); + throw new CommandException("解析后的 {$class} 不是对象。"); } return $instance; diff --git a/app/command/EpayMockChainTest.php b/app/command/EpayMockChainTest.php new file mode 100644 index 0000000..7223e47 --- /dev/null +++ b/app/command/EpayMockChainTest.php @@ -0,0 +1,1314 @@ +setDescription('自动写入测试配置并运行 epay_v1 / epay_v2 mock 全链路测试。') + ->addOption('only', null, InputOption::VALUE_OPTIONAL, '只运行指定协议:v1 / v2 / all', 'all'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $only = strtolower(trim($this->optionString($input, 'only', 'all'))); + if (!in_array($only, ['all', 'v1', 'v2'], true)) { + $output->writeln('--only 只支持 v1 / v2 / all'); + return self::FAILURE; + } + + $this->initLogFile(); + $this->log($output, '开始执行 mock 全链路测试'); + $this->log($output, '日志文件: ' . $this->logFile); + + try { + $this->refreshPlugins($output); + $paymentType = $this->resolveAlipayType(); + $summary = []; + + if ($only === 'all' || $only === 'v1') { + $summary['v1'] = $this->runV1Chain($output, $paymentType); + } + + if ($only === 'all' || $only === 'v2') { + $summary['v2'] = $this->runV2Chain($output, $paymentType); + } + + $this->logJson($output, '最终汇总', $summary); + $this->log($output, 'mock 全链路测试完成'); + + return self::SUCCESS; + } catch (Throwable $e) { + $this->log($output, '[失败] ' . $this->formatThrowable($e)); + + return self::FAILURE; + } + } + + /** + * 运行 V1 全链路。 + * + * @param OutputInterface $output 输出对象 + * @param PaymentType $paymentType 支付方式 + * @return array + */ + private function runV1Chain(OutputInterface $output, PaymentType $paymentType): array + { + $this->log($output, '====== V1 mock 链路开始 ======'); + $context = $this->ensureV1Context($output, $paymentType); + /** @var Merchant $merchant */ + $merchant = $context['merchant']; + /** @var MerchantApiCredential $credential */ + $credential = $context['credential']; + + $submitOutTradeNo = $this->buildOrderNo('V1SUB'); + $submitPayload = [ + 'pid' => (int) $merchant->id, + 'type' => 'alipay', + 'out_trade_no' => $submitOutTradeNo, + 'notify_url' => 'http://127.0.0.1:1/mock-notify/v1-submit', + 'return_url' => 'https://mock.return.test/v1-submit', + 'name' => 'V1 页面跳转支付测试', + 'money' => '1.00', + 'param' => 'v1-submit', + 'sign_type' => AuthConstant::API_SIGN_NAME_MD5, + ]; + $submitPayload['sign'] = $this->signMd5Payload($submitPayload, (string) $credential->api_key); + $submitResponse = $this->resolve(EpayV1Controller::class)->submit($this->buildRequest($submitPayload, '/submit.php')); + $submitSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $submitOutTradeNo); + $this->assertTrue(isset($submitSnapshot['pay_order']), 'V1 submit 未创建支付单'); + $this->logJson($output, 'V1 submit 响应', $this->describeHttpResponse($submitResponse)); + $this->logJson($output, 'V1 submit 订单快照', $this->formatOrderSnapshot($submitSnapshot)); + + $mapiOutTradeNo = $this->buildOrderNo('V1MAP'); + $mapiPayload = [ + 'pid' => (int) $merchant->id, + 'type' => 'alipay', + 'out_trade_no' => $mapiOutTradeNo, + 'notify_url' => 'http://127.0.0.1:1/mock-notify/v1-mapi', + 'return_url' => 'https://mock.return.test/v1-mapi', + 'name' => 'V1 接口支付测试', + 'money' => '1.00', + 'param' => 'v1-mapi', + 'clientip' => '127.0.0.1', + 'device' => 'pc', + 'sign_type' => AuthConstant::API_SIGN_NAME_MD5, + ]; + $mapiPayload['sign'] = $this->signMd5Payload($mapiPayload, (string) $credential->api_key); + $mapiResponse = $this->decodeJsonResponse( + $this->resolve(EpayV1Controller::class)->mapi($this->buildRequest($mapiPayload, '/mapi.php'))->rawBody() + ); + $this->assertTrue((int) ($mapiResponse['code'] ?? 0) === 1, 'V1 mapi 返回失败'); + $mapiSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $mapiOutTradeNo); + /** @var \app\model\payment\PayOrder $mapiPayOrder */ + $mapiPayOrder = $mapiSnapshot['pay_order']; + /** @var BizOrder $mapiBizOrder */ + $mapiBizOrder = $mapiSnapshot['biz_order']; + $this->logJson($output, 'V1 mapi 响应', $mapiResponse); + $this->logJson($output, 'V1 mapi 订单快照', $this->formatOrderSnapshot($mapiSnapshot)); + + $v1CallbackPayload = $this->buildV1CallbackPayload($merchant, $mapiBizOrder, $mapiPayOrder, (string) $context['upstream_key']); + $v1CallbackAck = $this->resolve(EpayV2Controller::class)->callback( + $this->buildRequest($v1CallbackPayload, '/api/pay/' . $mapiPayOrder->pay_no . '/callback'), + (string) $mapiPayOrder->pay_no + ); + $mapiAfterCallback = $this->loadOrderSnapshot((int) $merchant->id, $mapiOutTradeNo); + $this->assertTrue((int) $mapiAfterCallback['pay_order']->status === TradeConstant::ORDER_STATUS_SUCCESS, 'V1 回调后支付单未成功'); + $this->log($output, 'V1 callback ACK: ' . $this->stringifyCallbackResult($v1CallbackAck)); + $this->logJson($output, 'V1 callback 订单快照', $this->formatOrderSnapshot($mapiAfterCallback)); + $this->logJson($output, 'V1 callback 日志', $this->formatCallbackLog($this->loadLatestPayCallbackLog((string) $mapiPayOrder->pay_no))); + $this->logJson($output, 'V1 商户通知任务', $this->formatNotifyTask($this->loadLatestNotifyTask((string) $mapiPayOrder->pay_no))); + + $queryMerchantResponse = $this->callV1Api([ + 'act' => 'query', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + ]); + $this->assertTrue((int) ($queryMerchantResponse['code'] ?? 0) === 1, 'V1 api query 返回失败'); + $this->logJson($output, 'V1 api query', $queryMerchantResponse); + + $queryOrderResponse = $this->callV1Api([ + 'act' => 'order', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'trade_no' => (string) $mapiPayOrder->pay_no, + ]); + $this->assertTrue((int) ($queryOrderResponse['code'] ?? 0) === 1, 'V1 api order 返回失败'); + $this->logJson($output, 'V1 api order', $queryOrderResponse); + + $queryOrdersResponse = $this->callV1Api([ + 'act' => 'orders', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'page' => 1, + 'limit' => 5, + ]); + $this->assertTrue((int) ($queryOrdersResponse['code'] ?? 0) === 1, 'V1 api orders 返回失败'); + $this->logJson($output, 'V1 api orders', $queryOrdersResponse); + + $refundResponse = $this->callV1Api([ + 'act' => 'refund', + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'trade_no' => (string) $mapiPayOrder->pay_no, + 'money' => '1.00', + ]); + $this->assertTrue((int) ($refundResponse['code'] ?? 0) === 1, 'V1 api refund 返回失败'); + $refundOrder = $this->loadRefundOrderByPayNo((string) $mapiPayOrder->pay_no); + $this->assertTrue($refundOrder instanceof RefundOrder, 'V1 退款单未落库'); + $this->assertTrue((int) $refundOrder->status === TradeConstant::REFUND_STATUS_SUCCESS, 'V1 退款单未成功'); + $this->logJson($output, 'V1 api refund', $refundResponse); + $this->logJson($output, 'V1 refund 订单快照', $this->formatRefundOrder($refundOrder)); + + $summary = [ + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + 'submit_pay_no' => (string) $submitSnapshot['pay_order']->pay_no, + 'mapi_pay_no' => (string) $mapiPayOrder->pay_no, + 'mapi_status' => (int) $mapiAfterCallback['pay_order']->status, + 'refund_no' => (string) $refundOrder->refund_no, + 'refund_status' => (int) $refundOrder->status, + ]; + $this->logJson($output, 'V1 汇总', $summary); + $this->log($output, '====== V1 mock 链路完成 ======'); + + return $summary; + } + + /** + * 运行 V2 全链路。 + * + * @param OutputInterface $output 输出对象 + * @param PaymentType $paymentType 支付方式 + * @return array + */ + private function runV2Chain(OutputInterface $output, PaymentType $paymentType): array + { + $this->log($output, '====== V2 mock 链路开始 ======'); + $context = $this->ensureV2Context($output, $paymentType); + /** @var Merchant $merchant */ + $merchant = $context['merchant']; + $merchantPrivateKey = (string) $context['merchant_private_key']; + $mockPlatformPrivateKey = (string) $context['mock_platform_private_key']; + $platformPublicKey = $this->resolvePlatformPublicKey(); + + $submitOutTradeNo = $this->buildOrderNo('V2SUB'); + $submitPayload = $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + 'type' => 'alipay', + 'out_trade_no' => $submitOutTradeNo, + 'notify_url' => 'http://127.0.0.1:1/mock-notify/v2-submit', + 'return_url' => 'https://mock.return.test/v2-submit', + 'name' => 'V2 页面跳转支付测试', + 'money' => '1.00', + 'param' => 'v2-submit', + ], $merchantPrivateKey); + $submitResponse = $this->resolve(EpayV2Controller::class)->submit($this->buildRequest($submitPayload, '/api/pay/submit')); + $submitSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $submitOutTradeNo); + $this->assertTrue(isset($submitSnapshot['pay_order']), 'V2 submit 未创建支付单'); + /** @var \app\model\payment\PayOrder $submitPayOrder */ + $submitPayOrder = $submitSnapshot['pay_order']; + $this->logJson($output, 'V2 submit 响应', $this->describeHttpResponse($submitResponse)); + $this->logJson($output, 'V2 submit 订单快照', $this->formatOrderSnapshot($submitSnapshot)); + + $closeResponse = $this->callV2JsonEndpoint('close', $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + 'trade_no' => (string) $submitPayOrder->pay_no, + ], $merchantPrivateKey)); + $closeVerify = $this->verifyV2ResponseSignature($closeResponse, $platformPublicKey); + $this->assertTrue((int) ($closeResponse['code'] ?? -1) === 0, 'V2 close 返回失败'); + $this->assertTrue($closeVerify['passed'], 'V2 close 响应验签失败'); + $submitAfterClose = $this->loadOrderSnapshot((int) $merchant->id, $submitOutTradeNo); + $this->assertTrue((int) $submitAfterClose['pay_order']->status === TradeConstant::ORDER_STATUS_CLOSED, 'V2 submit 单未关闭'); + $this->logJson($output, 'V2 close 响应', $closeResponse); + $this->logJson($output, 'V2 close 订单快照', $this->formatOrderSnapshot($submitAfterClose)); + + $createOutTradeNo = $this->buildOrderNo('V2CRT'); + $createResponse = $this->callV2JsonEndpoint('create', $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + 'type' => 'alipay', + 'method' => 'web', + 'out_trade_no' => $createOutTradeNo, + 'notify_url' => 'http://127.0.0.1:1/mock-notify/v2-create', + 'return_url' => 'https://mock.return.test/v2-create', + 'name' => 'V2 API 支付测试', + 'money' => '1.00', + 'param' => 'v2-create', + 'clientip' => '127.0.0.1', + 'device' => 'pc', + ], $merchantPrivateKey)); + $createVerify = $this->verifyV2ResponseSignature($createResponse, $platformPublicKey); + $this->assertTrue((int) ($createResponse['code'] ?? -1) === 0, 'V2 create 返回失败'); + $this->assertTrue($createVerify['passed'], 'V2 create 响应验签失败'); + $createSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $createOutTradeNo); + /** @var \app\model\payment\PayOrder $createPayOrder */ + $createPayOrder = $createSnapshot['pay_order']; + /** @var BizOrder $createBizOrder */ + $createBizOrder = $createSnapshot['biz_order']; + $this->logJson($output, 'V2 create 响应', $createResponse); + $this->logJson($output, 'V2 create 订单快照', $this->formatOrderSnapshot($createSnapshot)); + + $v2CallbackPayload = $this->buildV2CallbackPayload($merchant, $createBizOrder, $createPayOrder, $mockPlatformPrivateKey); + $v2CallbackAck = $this->resolve(EpayV2Controller::class)->callback( + $this->buildRequest($v2CallbackPayload, '/api/pay/' . $createPayOrder->pay_no . '/callback'), + (string) $createPayOrder->pay_no + ); + $createAfterCallback = $this->loadOrderSnapshot((int) $merchant->id, $createOutTradeNo); + $this->assertTrue((int) $createAfterCallback['pay_order']->status === TradeConstant::ORDER_STATUS_SUCCESS, 'V2 回调后支付单未成功'); + $this->log($output, 'V2 callback ACK: ' . $this->stringifyCallbackResult($v2CallbackAck)); + $this->logJson($output, 'V2 callback 订单快照', $this->formatOrderSnapshot($createAfterCallback)); + $this->logJson($output, 'V2 callback 日志', $this->formatCallbackLog($this->loadLatestPayCallbackLog((string) $createPayOrder->pay_no))); + $this->logJson($output, 'V2 商户通知任务', $this->formatNotifyTask($this->loadLatestNotifyTask((string) $createPayOrder->pay_no))); + + $queryResponse = $this->callV2JsonEndpoint('query', $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + 'trade_no' => (string) $createPayOrder->pay_no, + ], $merchantPrivateKey)); + $queryVerify = $this->verifyV2ResponseSignature($queryResponse, $platformPublicKey); + $this->assertTrue((int) ($queryResponse['code'] ?? -1) === 0, 'V2 query 返回失败'); + $this->assertTrue($queryVerify['passed'], 'V2 query 响应验签失败'); + $this->assertTrue((int) ($queryResponse['status'] ?? 0) === 1, 'V2 query 状态不正确'); + $this->logJson($output, 'V2 query 响应', $queryResponse); + + $merchantInfoResponse = $this->callV2JsonEndpoint('merchantInfo', $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + ], $merchantPrivateKey)); + $merchantInfoVerify = $this->verifyV2ResponseSignature($merchantInfoResponse, $platformPublicKey); + $this->assertTrue((int) ($merchantInfoResponse['code'] ?? -1) === 0, 'V2 merchantInfo 返回失败'); + $this->assertTrue($merchantInfoVerify['passed'], 'V2 merchantInfo 响应验签失败'); + $this->logJson($output, 'V2 merchantInfo 响应', $merchantInfoResponse); + + $merchantOrdersResponse = $this->callV2JsonEndpoint('merchantOrders', $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + 'offset' => 0, + 'limit' => 5, + ], $merchantPrivateKey)); + $merchantOrdersVerify = $this->verifyV2ResponseSignature($merchantOrdersResponse, $platformPublicKey); + $this->assertTrue((int) ($merchantOrdersResponse['code'] ?? -1) === 0, 'V2 merchantOrders 返回失败'); + $this->assertTrue($merchantOrdersVerify['passed'], 'V2 merchantOrders 响应验签失败'); + $this->logJson($output, 'V2 merchantOrders 响应', $merchantOrdersResponse); + + $outRefundNo = $this->buildRefundNo('V2REF'); + $refundResponse = $this->callV2JsonEndpoint('refund', $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + 'trade_no' => (string) $createPayOrder->pay_no, + 'money' => '1.00', + 'out_refund_no' => $outRefundNo, + ], $merchantPrivateKey)); + $refundVerify = $this->verifyV2ResponseSignature($refundResponse, $platformPublicKey); + $this->assertTrue((int) ($refundResponse['code'] ?? -1) === 0, 'V2 refund 返回失败'); + $this->assertTrue($refundVerify['passed'], 'V2 refund 响应验签失败'); + $refundOrder = $this->loadRefundOrderByMerchantRefundNo((int) $merchant->id, $outRefundNo); + $this->assertTrue($refundOrder instanceof RefundOrder, 'V2 退款单未落库'); + $this->assertTrue((int) $refundOrder->status === TradeConstant::REFUND_STATUS_SUCCESS, 'V2 退款单未成功'); + $this->logJson($output, 'V2 refund 响应', $refundResponse); + $this->logJson($output, 'V2 refund 订单快照', $this->formatRefundOrder($refundOrder)); + + $refundQueryResponse = $this->callV2JsonEndpoint('refundQuery', $this->buildSignedV2Payload([ + 'pid' => (int) $merchant->id, + 'out_refund_no' => $outRefundNo, + ], $merchantPrivateKey)); + $refundQueryVerify = $this->verifyV2ResponseSignature($refundQueryResponse, $platformPublicKey); + $this->assertTrue((int) ($refundQueryResponse['code'] ?? -1) === 0, 'V2 refundQuery 返回失败'); + $this->assertTrue($refundQueryVerify['passed'], 'V2 refundQuery 响应验签失败'); + $this->assertTrue((int) ($refundQueryResponse['status'] ?? 0) === 1, 'V2 refundQuery 状态不正确'); + $this->logJson($output, 'V2 refundQuery 响应', $refundQueryResponse); + + $summary = [ + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + 'submit_pay_no' => (string) $submitPayOrder->pay_no, + 'submit_status' => (int) $submitAfterClose['pay_order']->status, + 'create_pay_no' => (string) $createPayOrder->pay_no, + 'create_status' => (int) $createAfterCallback['pay_order']->status, + 'refund_no' => (string) $refundOrder->refund_no, + 'refund_status' => (int) $refundOrder->status, + ]; + $this->logJson($output, 'V2 汇总', $summary); + $this->log($output, '====== V2 mock 链路完成 ======'); + + return $summary; + } + + /** + * 确保 V1 测试环境。 + * + * @param OutputInterface $output 输出对象 + * @param PaymentType $paymentType 支付方式 + * @return array + */ + private function ensureV1Context(OutputInterface $output, PaymentType $paymentType): array + { + $group = $this->ensureMerchantGroup('支付链路测试-V1'); + $merchant = $this->ensureMerchant([ + 'merchant_no' => 'MCHAINMOCKV1', + 'merchant_name' => '支付链路测试商户-V1', + 'merchant_short_name' => '链路测试V1', + 'group_id' => (int) $group->id, + 'remark' => '命令行 mock 全链路测试商户', + ]); + $this->ensureMerchantAccount((int) $merchant->id); + + $credential = MerchantApiCredential::query()->updateOrCreate( + ['merchant_id' => (int) $merchant->id], + [ + '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, + ] + ); + + $pluginConf = $this->ensurePluginConf('epay_v1', '支付链路测试-V1', [ + 'gateway_url' => 'mock://epay-v1', + 'upstream_pid' => '900001', + 'upstream_key' => 'mock-v1-upstream-key-20260423', + 'mock_enabled' => true, + 'mock_jump_base_url' => 'https://mock.epay.test/v1/pay', + 'type_mapping_json' => [ + 'alipay' => 'alipay', + 'wxpay' => 'wxpay', + ], + ]); + $channel = $this->ensureChannel( + (int) $merchant->id, + (int) $paymentType->id, + 'epay_v1', + (int) $pluginConf->id, + '支付链路测试-V1-支付宝通道' + ); + $pollGroup = $this->ensurePollGroup('支付链路测试路由-V1', (int) $paymentType->id); + $this->ensurePollGroupChannel((int) $pollGroup->id, (int) $channel->id); + $this->ensurePollGroupBind((int) $group->id, (int) $paymentType->id, (int) $pollGroup->id); + + $summary = [ + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + 'group_id' => (int) $group->id, + 'plugin_conf_id' => (int) $pluginConf->id, + 'channel_id' => (int) $channel->id, + 'poll_group_id' => (int) $pollGroup->id, + ]; + $this->logJson($output, 'V1 测试环境', $summary); + + return [ + 'merchant' => $merchant, + 'credential' => $credential, + 'upstream_key' => 'mock-v1-upstream-key-20260423', + ]; + } + + /** + * 确保 V2 测试环境。 + * + * @param OutputInterface $output 输出对象 + * @param PaymentType $paymentType 支付方式 + * @return array + */ + private function ensureV2Context(OutputInterface $output, PaymentType $paymentType): array + { + $group = $this->ensureMerchantGroup('支付链路测试-V2'); + $merchant = $this->ensureMerchant([ + 'merchant_no' => 'MCHAINMOCKV2', + 'merchant_name' => '支付链路测试商户-V2', + 'merchant_short_name' => '链路测试V2', + 'group_id' => (int) $group->id, + 'remark' => '命令行 mock 全链路测试商户', + ]); + $this->ensureMerchantAccount((int) $merchant->id); + + $merchantPair = RsaKeyPairGenerator::generate(); + $upstreamMerchantPair = RsaKeyPairGenerator::generate(); + $upstreamPlatformPair = RsaKeyPairGenerator::generate(); + + $credential = MerchantApiCredential::query()->updateOrCreate( + ['merchant_id' => (int) $merchant->id], + [ + '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, + ] + ); + + $merchantPrivateKeyPath = $this->writePemFile('epay/mock-chain/' . $merchant->merchant_no . '-merchant-private.pem', $merchantPair['private_key']); + $pluginConf = $this->ensurePluginConf('epay_v2', '支付链路测试-V2', [ + 'gateway_url' => 'mock://epay-v2', + 'upstream_pid' => '900002', + 'merchant_private_key' => $upstreamMerchantPair['private_key'], + 'platform_public_key' => $upstreamPlatformPair['public_key'], + 'mock_platform_private_key' => $upstreamPlatformPair['private_key'], + 'mock_enabled' => true, + 'mock_jump_base_url' => 'https://mock.epay.test/v2/pay', + 'type_mapping_json' => [ + 'alipay' => 'alipay', + 'wxpay' => 'wxpay', + 'unionpay' => 'bank', + ], + ]); + $channel = $this->ensureChannel( + (int) $merchant->id, + (int) $paymentType->id, + 'epay_v2', + (int) $pluginConf->id, + '支付链路测试-V2-支付宝通道' + ); + $pollGroup = $this->ensurePollGroup('支付链路测试路由-V2', (int) $paymentType->id); + $this->ensurePollGroupChannel((int) $pollGroup->id, (int) $channel->id); + $this->ensurePollGroupBind((int) $group->id, (int) $paymentType->id, (int) $pollGroup->id); + + $summary = [ + 'merchant_id' => (int) $merchant->id, + 'merchant_no' => (string) $merchant->merchant_no, + 'group_id' => (int) $group->id, + 'plugin_conf_id' => (int) $pluginConf->id, + 'channel_id' => (int) $channel->id, + 'poll_group_id' => (int) $pollGroup->id, + 'merchant_private_key_path' => $merchantPrivateKeyPath, + ]; + $this->logJson($output, 'V2 测试环境', $summary); + + return [ + 'merchant' => $merchant, + 'credential' => $credential, + 'merchant_private_key' => $merchantPair['private_key'], + 'merchant_private_key_path' => $merchantPrivateKeyPath, + 'mock_platform_private_key' => $upstreamPlatformPair['private_key'], + ]; + } + + /** + * 刷新插件定义。 + */ + private function refreshPlugins(OutputInterface $output): void + { + /** @var PaymentPluginSyncService $service */ + $service = $this->resolve(PaymentPluginSyncService::class); + $result = $service->refreshFromClasses(); + $this->logJson($output, '插件同步结果', [ + 'count' => (int) ($result['count'] ?? 0), + ]); + } + + /** + * 解析支付宝支付方式。 + */ + private function resolveAlipayType(): PaymentType + { + /** @var PaymentTypeRepository $repository */ + $repository = $this->resolve(PaymentTypeRepository::class); + $paymentType = $repository->findByCode('alipay'); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('未找到可用的 alipay 支付方式'); + } + + return $paymentType; + } + + /** + * 确保商户分组存在。 + */ + private function ensureMerchantGroup(string $groupName): MerchantGroup + { + $group = MerchantGroup::query()->where('group_name', $groupName)->first(); + if (!$group) { + $group = new MerchantGroup(); + $group->group_name = $groupName; + } + + $group->status = CommonConstant::STATUS_ENABLED; + $group->remark = '命令行 mock 全链路测试'; + $group->save(); + + return $group->refresh(); + } + + /** + * 确保商户存在。 + * + * @param array $data 商户数据 + */ + private function ensureMerchant(array $data): Merchant + { + $merchant = Merchant::query()->where('merchant_no', (string) $data['merchant_no'])->first(); + if (!$merchant) { + $merchant = new Merchant(); + $merchant->merchant_no = (string) $data['merchant_no']; + } + + $merchant->password_hash = password_hash('123456', PASSWORD_BCRYPT); + $merchant->merchant_name = (string) $data['merchant_name']; + $merchant->merchant_short_name = (string) $data['merchant_short_name']; + $merchant->merchant_type = 0; + $merchant->group_id = (int) $data['group_id']; + $merchant->risk_level = 0; + $merchant->contact_name = 'Mock Tester'; + $merchant->contact_phone = '13800000000'; + $merchant->contact_email = 'mock@example.test'; + $merchant->settlement_account_name = 'Mock Tester'; + $merchant->settlement_account_no = '6222020202020202020'; + $merchant->settlement_bank_name = 'Mock Bank'; + $merchant->settlement_bank_branch = 'Mock Branch'; + $merchant->status = CommonConstant::STATUS_ENABLED; + $merchant->pay_status = CommonConstant::STATUS_ENABLED; + $merchant->settle_status = CommonConstant::STATUS_ENABLED; + $merchant->settle_type = 4; + $merchant->last_login_ip = ''; + $merchant->password_updated_at = date('Y-m-d H:i:s'); + $merchant->remark = (string) ($data['remark'] ?? ''); + $merchant->save(); + + return $merchant->refresh(); + } + + /** + * 确保商户账户存在。 + */ + private function ensureMerchantAccount(int $merchantId): MerchantAccount + { + $account = MerchantAccount::query()->where('merchant_id', $merchantId)->first(); + if (!$account) { + $account = new MerchantAccount(); + $account->merchant_id = $merchantId; + } + + $account->available_balance = 100000000; + $account->frozen_balance = 0; + $account->save(); + + return $account->refresh(); + } + + /** + * 确保插件配置存在。 + * + * @param array $config 配置内容 + */ + private function ensurePluginConf(string $pluginCode, string $remark, array $config): PaymentPluginConf + { + $pluginConf = PaymentPluginConf::query() + ->where('plugin_code', $pluginCode) + ->where('remark', $remark) + ->first(); + + if (!$pluginConf) { + $pluginConf = new PaymentPluginConf(); + $pluginConf->plugin_code = $pluginCode; + } + + $pluginConf->config = $config; + $pluginConf->settlement_cycle_type = TradeConstant::SETTLEMENT_CYCLE_D1; + $pluginConf->settlement_cutoff_time = '23:59:59'; + $pluginConf->remark = $remark; + $pluginConf->save(); + + return $pluginConf->refresh(); + } + + /** + * 确保支付通道存在。 + */ + private function ensureChannel(int $merchantId, int $payTypeId, string $pluginCode, int $pluginConfId, string $name): PaymentChannel + { + $channel = PaymentChannel::query()->where('name', $name)->first(); + if (!$channel) { + $channel = new PaymentChannel(); + $channel->name = $name; + } + + $channel->merchant_id = $merchantId; + $channel->split_rate_bp = 10000; + $channel->cost_rate_bp = 0; + $channel->channel_mode = RouteConstant::CHANNEL_MODE_COLLECT; + $channel->pay_type_id = $payTypeId; + $channel->plugin_code = $pluginCode; + $channel->api_config_id = $pluginConfId; + $channel->daily_limit_amount = 0; + $channel->daily_limit_count = 0; + $channel->min_amount = 0; + $channel->max_amount = 0; + $channel->remark = '命令行 mock 全链路测试通道'; + $channel->status = CommonConstant::STATUS_ENABLED; + $channel->sort_no = 0; + $channel->save(); + + return $channel->refresh(); + } + + /** + * 确保轮询组存在。 + */ + private function ensurePollGroup(string $groupName, int $payTypeId): PaymentPollGroup + { + $pollGroup = PaymentPollGroup::query()->where('group_name', $groupName)->first(); + if (!$pollGroup) { + $pollGroup = new PaymentPollGroup(); + $pollGroup->group_name = $groupName; + } + + $pollGroup->pay_type_id = $payTypeId; + $pollGroup->route_mode = RouteConstant::ROUTE_MODE_ORDER; + $pollGroup->status = CommonConstant::STATUS_ENABLED; + $pollGroup->remark = '命令行 mock 全链路测试路由组'; + $pollGroup->save(); + + return $pollGroup->refresh(); + } + + /** + * 确保轮询组通道编排存在。 + */ + private function ensurePollGroupChannel(int $pollGroupId, int $channelId): PaymentPollGroupChannel + { + $row = PaymentPollGroupChannel::query() + ->where('poll_group_id', $pollGroupId) + ->where('channel_id', $channelId) + ->first(); + if (!$row) { + $row = new PaymentPollGroupChannel(); + $row->poll_group_id = $pollGroupId; + $row->channel_id = $channelId; + } + + $row->sort_no = 0; + $row->weight = 100; + $row->is_default = CommonConstant::YES; + $row->status = CommonConstant::STATUS_ENABLED; + $row->remark = '命令行 mock 全链路测试编排'; + $row->save(); + + PaymentPollGroupChannel::query() + ->where('poll_group_id', $pollGroupId) + ->where('channel_id', '<>', $channelId) + ->update(['is_default' => 0]); + + return $row->refresh(); + } + + /** + * 确保轮询组绑定存在。 + */ + private function ensurePollGroupBind(int $merchantGroupId, int $payTypeId, int $pollGroupId): PaymentPollGroupBind + { + $bind = PaymentPollGroupBind::query() + ->where('merchant_group_id', $merchantGroupId) + ->where('pay_type_id', $payTypeId) + ->first(); + if (!$bind) { + $bind = new PaymentPollGroupBind(); + $bind->merchant_group_id = $merchantGroupId; + $bind->pay_type_id = $payTypeId; + } + + $bind->poll_group_id = $pollGroupId; + $bind->status = CommonConstant::STATUS_ENABLED; + $bind->remark = '命令行 mock 全链路测试绑定'; + $bind->save(); + + return $bind->refresh(); + } + + /** + * 调用 V1 兼容 API。 + * + * @param array $payload 请求参数 + * @return array + */ + private function callV1Api(array $payload): array + { + return $this->decodeJsonResponse( + $this->resolve(EpayV1Controller::class)->api($this->buildRequest($payload, '/api.php'))->rawBody() + ); + } + + /** + * 调用 V2 JSON 接口。 + * + * @param string $action 动作名 + * @param array $payload 请求参数 + * @return array + */ + private function callV2JsonEndpoint(string $action, array $payload): array + { + /** @var EpayV2Controller $controller */ + $controller = $this->resolve(EpayV2Controller::class); + $request = $this->buildRequest($payload, match ($action) { + 'create' => '/api/pay/create', + 'query' => '/api/pay/query', + 'refund' => '/api/pay/refund', + 'refundQuery' => '/api/pay/refundquery', + 'close' => '/api/pay/close', + 'merchantInfo' => '/api/merchant/info', + 'merchantOrders' => '/api/merchant/orders', + default => throw new CommandException('不支持的 V2 动作: ' . $action), + }); + + $response = match ($action) { + 'create' => $controller->create($request), + 'query' => $controller->query($request), + 'refund' => $controller->refund($request), + 'refundQuery' => $controller->refundQuery($request), + 'close' => $controller->close($request), + 'merchantInfo' => $controller->merchantInfo($request), + 'merchantOrders' => $controller->merchantOrders($request), + default => throw new CommandException('不支持的 V2 动作: ' . $action), + }; + + return $this->decodeJsonResponse($response->rawBody()); + } + + /** + * 构建表单请求。 + * + * @param array $payload 请求参数 + * @param string $path 路径 + */ + private function buildRequest(array $payload, string $path): Request + { + $body = http_build_query($payload, '', '&', PHP_QUERY_RFC1738); + $siteUrl = $this->resolveSiteUrl(); + $host = parse_url($siteUrl, PHP_URL_HOST) ?: 'localhost'; + $port = parse_url($siteUrl, PHP_URL_PORT); + $hostHeader = $port ? sprintf('%s:%s', $host, $port) : $host; + $path = '/' . ltrim($path, '/'); + + $rawRequest = implode("\r\n", [ + 'POST ' . $path . ' HTTP/1.1', + 'Host: ' . $hostHeader, + 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', + 'Content-Length: ' . strlen($body), + 'Connection: close', + '', + $body, + ]); + + return new Request($rawRequest); + } + + /** + * 构建 V2 已签名载荷。 + * + * @param array $payload 原始参数 + * @return array + */ + private function buildSignedV2Payload(array $payload, string $privateKey): array + { + /** @var EpaySignerManager $signerManager */ + $signerManager = $this->resolve(EpaySignerManager::class); + $payload['timestamp'] = (string) time(); + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA; + $signPayload = $payload; + unset($signPayload['sign'], $signPayload['sign_type']); + $payload['sign'] = $signerManager->sign($signPayload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $privateKey); + + return $payload; + } + + /** + * 校验 V2 响应签名。 + * + * @param array $responseData 响应参数 + * @return array{passed: bool, message: string} + */ + private function verifyV2ResponseSignature(array $responseData, string $platformPublicKey): array + { + /** @var EpaySignerManager $signerManager */ + $signerManager = $this->resolve(EpaySignerManager::class); + $sign = trim((string) ($responseData['sign'] ?? '')); + if ($sign === '') { + 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)) { + return ['passed' => false, 'message' => '响应验签失败']; + } + + return ['passed' => true, 'message' => 'success']; + } + + /** + * 生成 V1 MD5 签名。 + * + * @param array $payload 参数 + */ + private function signMd5Payload(array $payload, string $key): string + { + /** @var EpaySignerManager $signerManager */ + $signerManager = $this->resolve(EpaySignerManager::class); + + return $signerManager->sign($payload, AuthConstant::API_SIGN_NAME_MD5, $key); + } + + /** + * 构建 V1 回调参数。 + * + * @return array + */ + private function buildV1CallbackPayload(Merchant $merchant, BizOrder $bizOrder, $payOrder, string $upstreamKey): array + { + $payload = [ + 'pid' => (int) $merchant->id, + 'trade_no' => (string) $payOrder->channel_order_no, + 'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no), + 'out_trade_no' => (string) $bizOrder->merchant_order_no, + 'type' => 'alipay', + 'name' => (string) $bizOrder->subject, + 'money' => FormatHelper::amount((int) $payOrder->pay_amount), + 'trade_status' => 'TRADE_SUCCESS', + 'param' => (string) (((array) ($bizOrder->ext_json['merchant'] ?? []))['param'] ?? ''), + 'endtime' => FormatHelper::dateTime($this->nowDateTime()), + 'sign_type' => AuthConstant::API_SIGN_NAME_MD5, + ]; + $signPayload = $payload; + unset($signPayload['sign'], $signPayload['sign_type']); + $payload['sign'] = $this->signMd5Payload($signPayload, $upstreamKey); + + return $payload; + } + + /** + * 构建 V2 回调参数。 + * + * @return array + */ + private function buildV2CallbackPayload(Merchant $merchant, BizOrder $bizOrder, $payOrder, string $mockPlatformPrivateKey): array + { + /** @var EpaySignerManager $signerManager */ + $signerManager = $this->resolve(EpaySignerManager::class); + $payload = [ + 'pid' => (int) $merchant->id, + 'trade_no' => (string) $payOrder->channel_order_no, + 'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no), + 'out_trade_no' => (string) $bizOrder->merchant_order_no, + 'type' => 'alipay', + 'name' => (string) $bizOrder->subject, + 'money' => FormatHelper::amount((int) $payOrder->pay_amount), + 'trade_status' => 'TRADE_SUCCESS', + '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, + ]; + $signPayload = $payload; + unset($signPayload['sign'], $signPayload['sign_type']); + $payload['sign'] = $signerManager->sign($signPayload, AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA, $mockPlatformPrivateKey); + + return $payload; + } + + /** + * 加载订单快照。 + * + * @return array{biz_order: BizOrder|null, pay_order: mixed, refund_order: RefundOrder|null} + */ + private function loadOrderSnapshot(int $merchantId, string $merchantOrderNo): array + { + /** @var BizOrderRepository $bizOrderRepository */ + $bizOrderRepository = $this->resolve(BizOrderRepository::class); + /** @var PayOrderRepository $payOrderRepository */ + $payOrderRepository = $this->resolve(PayOrderRepository::class); + /** @var RefundOrderRepository $refundOrderRepository */ + $refundOrderRepository = $this->resolve(RefundOrderRepository::class); + + $bizOrder = $bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo); + $payOrder = $bizOrder ? $payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no) : null; + $refundOrder = $payOrder ? $refundOrderRepository->findByPayNo((string) $payOrder->pay_no) : null; + + return [ + 'biz_order' => $bizOrder, + 'pay_order' => $payOrder, + 'refund_order' => $refundOrder, + ]; + } + + /** + * 根据支付单号加载最新回调日志。 + */ + private function loadLatestPayCallbackLog(string $payNo): ?PayCallbackLog + { + return PayCallbackLog::query()->where('pay_no', $payNo)->orderByDesc('id')->first(); + } + + /** + * 根据支付单号加载最新商户通知任务。 + */ + private function loadLatestNotifyTask(string $payNo): ?NotifyTask + { + return NotifyTask::query()->where('pay_no', $payNo)->orderByDesc('id')->first(); + } + + /** + * 根据支付单号加载退款单。 + */ + private function loadRefundOrderByPayNo(string $payNo): ?RefundOrder + { + /** @var RefundOrderRepository $repository */ + $repository = $this->resolve(RefundOrderRepository::class); + return $repository->findByPayNo($payNo); + } + + /** + * 根据商户退款单号加载退款单。 + */ + private function loadRefundOrderByMerchantRefundNo(int $merchantId, string $merchantRefundNo): ?RefundOrder + { + /** @var RefundOrderRepository $repository */ + $repository = $this->resolve(RefundOrderRepository::class); + return $repository->findByMerchantRefundNo($merchantId, $merchantRefundNo); + } + + /** + * 输出订单快照。 + * + * @param array{biz_order: BizOrder|null, pay_order: mixed, refund_order: RefundOrder|null} $snapshot + * @return array + */ + private function formatOrderSnapshot(array $snapshot): array + { + /** @var BizOrder|null $bizOrder */ + $bizOrder = $snapshot['biz_order']; + $payOrder = $snapshot['pay_order']; + /** @var RefundOrder|null $refundOrder */ + $refundOrder = $snapshot['refund_order']; + + return [ + 'biz_order' => $bizOrder ? [ + 'biz_no' => (string) $bizOrder->biz_no, + 'merchant_order_no' => (string) $bizOrder->merchant_order_no, + 'status' => (int) $bizOrder->status, + 'active_pay_no' => (string) ($bizOrder->active_pay_no ?? ''), + 'paid_amount' => (int) ($bizOrder->paid_amount ?? 0), + 'refund_amount' => (int) ($bizOrder->refund_amount ?? 0), + ] : null, + 'pay_order' => $payOrder ? [ + 'pay_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'status' => (int) $payOrder->status, + 'channel_order_no' => (string) ($payOrder->channel_order_no ?? ''), + 'channel_trade_no' => (string) ($payOrder->channel_trade_no ?? ''), + 'callback_status' => (int) ($payOrder->callback_status ?? 0), + 'refund_status' => (int) ($payOrder->refund_status ?? 0), + 'ext_json' => (array) ($payOrder->ext_json ?? []), + ] : null, + 'refund_order' => $refundOrder ? $this->formatRefundOrder($refundOrder) : null, + ]; + } + + /** + * 格式化退款单。 + * + * @return array + */ + private function formatRefundOrder(RefundOrder $refundOrder): array + { + return [ + 'refund_no' => (string) $refundOrder->refund_no, + 'merchant_refund_no' => (string) $refundOrder->merchant_refund_no, + 'pay_no' => (string) $refundOrder->pay_no, + 'status' => (int) $refundOrder->status, + 'channel_refund_no' => (string) ($refundOrder->channel_refund_no ?? ''), + 'refund_amount' => (int) ($refundOrder->refund_amount ?? 0), + ]; + } + + /** + * 格式化回调日志。 + * + * @return array|null + */ + private function formatCallbackLog(?PayCallbackLog $log): ?array + { + if (!$log) { + return null; + } + + return [ + 'pay_no' => (string) $log->pay_no, + 'verify_status' => (int) $log->verify_status, + 'process_status' => (int) $log->process_status, + 'created_at' => FormatHelper::dateTime($log->created_at), + ]; + } + + /** + * 格式化商户通知任务。 + * + * @return array|null + */ + private function formatNotifyTask(?NotifyTask $task): ?array + { + if (!$task) { + return null; + } + + return [ + 'notify_no' => (string) $task->notify_no, + 'status' => (int) $task->status, + 'retry_count' => (int) $task->retry_count, + 'notify_url' => (string) $task->notify_url, + 'last_response' => (string) ($task->last_response ?? ''), + ]; + } + + /** + * 描述 HTTP 响应。 + * + * @return array + */ + private function describeHttpResponse(Response $response): array + { + $body = method_exists($response, 'rawBody') + ? $response->rawBody() + : (method_exists($response, 'getContent') ? (string) $response->getContent() : ''); + $location = method_exists($response, 'getHeader') ? $response->getHeader('Location') : null; + if (is_array($location)) { + $location = $location[0] ?? null; + } + + return [ + 'status' => method_exists($response, 'getStatusCode') ? $response->getStatusCode() : 200, + 'location' => $location, + 'body' => $this->limitString((string) $body, 300), + ]; + } + + /** + * 输出 callback 返回值。 + */ + private function stringifyCallbackResult(string|Response $result): string + { + if ($result instanceof Response) { + return FormatHelper::json($this->describeHttpResponse($result)); + } + + return trim($result); + } + + /** + * 解析 JSON 响应。 + * + * @return array + */ + private function decodeJsonResponse(string $body): array + { + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : ['raw' => $body]; + } + + /** + * 解析站点地址。 + */ + private function resolveSiteUrl(): string + { + $siteUrl = trim((string) sys_config('site_url')); + return $siteUrl !== '' ? rtrim($siteUrl, '/') : 'http://localhost:8787'; + } + + /** + * 解析平台公钥。 + */ + private function resolvePlatformPublicKey(): string + { + $publicKey = trim((string) config('epay.v2.platform_public_key', '')); + if ($publicKey === '') { + throw new CommandException('平台公钥未配置'); + } + + return $publicKey; + } + + /** + * 写入 PEM 文件。 + */ + private function writePemFile(string $relativePath, string $content): string + { + $path = runtime_path($relativePath); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + file_put_contents($path, trim($content) . PHP_EOL); + + return $path; + } + + /** + * 构建订单号。 + */ + private function buildOrderNo(string $prefix): string + { + return strtoupper($prefix) . date('YmdHis') . substr(md5((string) microtime(true) . $prefix), 0, 8); + } + + /** + * 构建退款单号。 + */ + private function buildRefundNo(string $prefix): string + { + return strtoupper($prefix) . date('YmdHis') . substr(md5((string) microtime(true) . $prefix . 'refund'), 0, 8); + } + + /** + * 当前时间对象。 + */ + private function nowDateTime(): \DateTimeImmutable + { + return new \DateTimeImmutable('now'); + } + + /** + * 断言条件。 + */ + private function assertTrue(bool $condition, string $message): void + { + if (!$condition) { + throw new CommandException($message); + } + } + + /** + * 初始化日志文件。 + */ + private function initLogFile(): void + { + $dir = runtime_path('logs/epay-mock-chain'); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + $this->logFile = $dir . DIRECTORY_SEPARATOR . 'epay-mock-chain-' . date('Ymd-His') . '.log'; + } + + /** + * 输出文本日志。 + */ + private function log(OutputInterface $output, string $message): void + { + $line = '[' . date('Y-m-d H:i:s') . '] ' . $message; + $output->writeln($line); + file_put_contents($this->logFile, $line . PHP_EOL, FILE_APPEND); + } + + /** + * 输出 JSON 日志。 + * + * @param mixed $data 任意数据 + */ + private function logJson(OutputInterface $output, string $title, mixed $data): void + { + $this->log($output, $title . ':'); + $json = FormatHelper::json($data); + foreach (preg_split('/\r\n|\r|\n/', $json) ?: [] as $line) { + $this->log($output, ' ' . $line); + } + } + + /** + * 截断文本。 + */ + private function limitString(string $value, int $length): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + return strlen($value) <= $length ? $value : substr($value, 0, $length) . '...'; + } + + /** + * 格式化异常。 + */ + private function formatThrowable(Throwable $e): string + { + $data = method_exists($e, 'getData') ? $e->getData() : []; + $suffix = is_array($data) && $data !== [] + ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + : ''; + + return $e::class . ':' . $e->getMessage() . $suffix; + } + + /** + * 读取字符串选项。 + */ + private function optionString(InputInterface $input, string $name, string $default = ''): string + { + $value = $input->getOption($name); + return $value === null || $value === false ? $default : (is_string($value) ? $value : (string) $value); + } + + /** + * 解析容器实例。 + */ + private function resolve(string $class): object + { + try { + $instance = container_make($class, []); + } catch (Throwable $e) { + throw new CommandException('无法解析 ' . $class, 50002, $e); + } + + if (!is_object($instance)) { + throw new CommandException('解析结果不是对象: ' . $class); + } + + return $instance; + } +} diff --git a/app/command/EpayV2ApiTest.php b/app/command/EpayV2ApiTest.php new file mode 100644 index 0000000..62a774a --- /dev/null +++ b/app/command/EpayV2ApiTest.php @@ -0,0 +1,1217 @@ +setDescription('自动读取真实商户、路由和 RSA 配置,测试 ePay V2 核心 API 是否能正常调用。') + ->addOption('live', null, InputOption::VALUE_NONE, '使用真实数据库并发起实际 V2 API 调用') + ->addOption('merchant-id', null, InputOption::VALUE_OPTIONAL, '指定商户 ID') + ->addOption('merchant-no', null, InputOption::VALUE_OPTIONAL, '指定商户号') + ->addOption('merchant-private-key', null, InputOption::VALUE_OPTIONAL, '商户 RSA 私钥内容') + ->addOption('merchant-private-key-file', null, InputOption::VALUE_OPTIONAL, '商户 RSA 私钥文件路径') + ->addOption('type', null, InputOption::VALUE_OPTIONAL, '支付方式编码,默认 alipay', 'alipay') + ->addOption('method', null, InputOption::VALUE_OPTIONAL, 'V2 接口方式,默认 web', 'web') + ->addOption('money', null, InputOption::VALUE_OPTIONAL, '支付金额,单位元,默认 1.00', '1.00') + ->addOption('device', null, InputOption::VALUE_OPTIONAL, '设备类型,默认 pc', 'pc') + ->addOption('out-trade-no', null, InputOption::VALUE_OPTIONAL, '商户订单号,默认自动生成') + ->addOption('skip-close', null, InputOption::VALUE_NONE, '创建订单后跳过关单校验') + ->addOption('refund-trade-no', null, InputOption::VALUE_OPTIONAL, '指定需要退款的 V2 平台订单号') + ->addOption('refund-out-trade-no', null, InputOption::VALUE_OPTIONAL, '指定需要退款的商户订单号') + ->addOption('refund-money', null, InputOption::VALUE_OPTIONAL, '退款金额,单位元') + ->addOption('out-refund-no', null, InputOption::VALUE_OPTIONAL, '商户退款单号,默认自动生成'); + } + + /** + * 执行 V2 核心 API 烟雾测试。 + * + * @param InputInterface $input 命令输入 + * @param OutputInterface $output 输出对象 + * @return int 命令退出码 + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('epay V2 核心 API 烟雾测试'); + + if (!$this->optionBool($input, 'live', false)) { + $this->ensureDependencies(); + $output->writeln('[通过] 依赖检查正常,使用 --live 才会真正发起 V2 API 请求。'); + + return self::SUCCESS; + } + + try { + $typeCode = trim($this->optionString($input, 'type', 'alipay')); + $method = $this->normalizeMethod($this->optionString($input, 'method', 'web')); + $money = $this->normalizeMoney($this->optionString($input, 'money', '1.00')); + $device = $this->normalizeDevice($this->optionString($input, 'device', 'pc')); + $merchantIdOption = $this->optionInt($input, 'merchant-id', 0); + $merchantNoOption = trim($this->optionString($input, 'merchant-no', '')); + $outTradeNo = $this->buildMerchantOrderNo(trim($this->optionString($input, 'out-trade-no', ''))); + $outRefundNo = $this->buildRefundNo(trim($this->optionString($input, 'out-refund-no', ''))); + $context = $this->discoverContext($merchantIdOption, $merchantNoOption, $typeCode); + $merchant = $context['merchant']; + $credential = $context['credential']; + $paymentType = $context['payment_type']; + $route = $context['route']; + $merchantPrivateKey = $this->resolveMerchantPrivateKey($input, (int) $merchant->id); + $platformPublicKey = $this->resolvePlatformPublicKey(); + $siteUrl = $this->resolveSiteUrl(); + + $this->ensureKeyPairMatches($merchantPrivateKey, (string) ($credential->merchant_public_key ?? '')); + + $output->writeln(sprintf( + '商户: id=%d no=%s name=%s group_id=%d', + (int) $merchant->id, + (string) $merchant->merchant_no, + (string) $merchant->merchant_name, + (int) $merchant->group_id + )); + $output->writeln(sprintf( + 'RSA: merchant_public_key=%s platform_public_key=%s', + FormatHelper::maskCredentialValue((string) ($credential->merchant_public_key ?? ''), false), + FormatHelper::maskCredentialValue($platformPublicKey, false) + )); + $output->writeln(sprintf( + '支付方式: %s(%d) 金额: %s 设备: %s method: %s', + (string) $paymentType->code, + (int) $paymentType->id, + $money, + $device, + $method + )); + $this->writeRouteSnapshot($output, $route); + + $allPassed = true; + $createPayload = $this->buildSignedPayload([ + 'pid' => (int) $merchant->id, + 'type' => (string) $paymentType->code, + 'method' => $method, + 'out_trade_no' => $outTradeNo, + 'notify_url' => $siteUrl . '/epay/v2/notify', + 'return_url' => $siteUrl . '/epay/v2/return', + 'name' => trim(sprintf('mpay epay v2 api smoke %s', (string) $merchant->merchant_name)), + 'money' => $money, + 'clientip' => '127.0.0.1', + 'device' => $device, + 'param' => 'v2-smoke', + ], $merchantPrivateKey); + $createResponse = $this->callJsonEndpoint('create', $createPayload, '/api/pay/create'); + $createVerify = $this->verifySignedResponse($createResponse, $platformPublicKey); + $orderSnapshot = $this->loadOrderSnapshot((int) $merchant->id, $outTradeNo); + $createPassed = $this->classifyCreateAttempt($createResponse, $orderSnapshot, $createVerify['passed']); + $allPassed = $allPassed && $createPassed; + $this->writeCreateAttempt($output, $createPayload, $createResponse, $orderSnapshot, $createVerify, $createPassed); + + $tradeNo = (string) ($createResponse['trade_no'] ?? ''); + if ($createPassed && $tradeNo !== '') { + $queryPayload = $this->buildSignedPayload([ + 'pid' => (int) $merchant->id, + 'trade_no' => $tradeNo, + ], $merchantPrivateKey); + $queryResponse = $this->callJsonEndpoint('query', $queryPayload, '/api/pay/query'); + $queryVerify = $this->verifySignedResponse($queryResponse, $platformPublicKey); + $queryPassed = $this->classifySimpleSuccess($queryResponse, $queryVerify['passed'], [ + 'trade_no' => $tradeNo, + 'out_trade_no' => $outTradeNo, + ]); + $allPassed = $allPassed && $queryPassed; + $this->writeSimpleAttempt($output, 'query', $queryPayload, $queryResponse, $queryVerify, $queryPassed, [ + 'trade_no', + 'out_trade_no', + 'status', + 'type', + 'money', + ]); + + $merchantInfoPayload = $this->buildSignedPayload([ + 'pid' => (int) $merchant->id, + ], $merchantPrivateKey); + $merchantInfoResponse = $this->callJsonEndpoint('merchantInfo', $merchantInfoPayload, '/api/merchant/info'); + $merchantInfoVerify = $this->verifySignedResponse($merchantInfoResponse, $platformPublicKey); + $merchantInfoPassed = $this->classifySimpleSuccess($merchantInfoResponse, $merchantInfoVerify['passed'], [ + 'pid' => (int) $merchant->id, + ]); + $allPassed = $allPassed && $merchantInfoPassed; + $this->writeSimpleAttempt($output, 'merchantInfo', $merchantInfoPayload, $merchantInfoResponse, $merchantInfoVerify, $merchantInfoPassed, [ + 'pid', + 'status', + 'money', + 'order_num', + 'order_num_today', + 'order_money_today', + ]); + + $merchantOrdersPayload = $this->buildSignedPayload([ + 'pid' => (int) $merchant->id, + 'offset' => 0, + 'limit' => 5, + 'status' => 0, + ], $merchantPrivateKey); + $merchantOrdersResponse = $this->callJsonEndpoint('merchantOrders', $merchantOrdersPayload, '/api/merchant/orders'); + $merchantOrdersVerify = $this->verifySignedResponse($merchantOrdersResponse, $platformPublicKey); + $merchantOrdersPassed = (int) ($merchantOrdersResponse['code'] ?? -1) === 0 + && $merchantOrdersVerify['passed'] + && is_array($merchantOrdersResponse['data'] ?? null); + $allPassed = $allPassed && $merchantOrdersPassed; + $this->writeSimpleAttempt($output, 'merchantOrders', $merchantOrdersPayload, $merchantOrdersResponse, $merchantOrdersVerify, $merchantOrdersPassed, []); + if (is_array($merchantOrdersResponse['data'] ?? null)) { + $output->writeln(sprintf(' 订单条数: %d', count($merchantOrdersResponse['data']))); + } + + if (!$this->optionBool($input, 'skip-close', false)) { + $closePayload = $this->buildSignedPayload([ + 'pid' => (int) $merchant->id, + 'trade_no' => $tradeNo, + ], $merchantPrivateKey); + $closeResponse = $this->callJsonEndpoint('close', $closePayload, '/api/pay/close'); + $closeVerify = $this->verifySignedResponse($closeResponse, $platformPublicKey); + $closePassed = $this->classifySimpleSuccess($closeResponse, $closeVerify['passed']); + $allPassed = $allPassed && $closePassed; + $this->writeSimpleAttempt($output, 'close', $closePayload, $closeResponse, $closeVerify, $closePassed, []); + } + } + + $refundTradeNo = trim($this->optionString($input, 'refund-trade-no', '')); + $refundOutTradeNo = trim($this->optionString($input, 'refund-out-trade-no', '')); + if ($refundTradeNo !== '' || $refundOutTradeNo !== '') { + $refundMoney = $this->normalizeMoney($this->optionString($input, 'refund-money', $money)); + $refundPayload = [ + 'pid' => (int) $merchant->id, + 'money' => $refundMoney, + 'out_refund_no' => $outRefundNo, + ]; + if ($refundTradeNo !== '') { + $refundPayload['trade_no'] = $refundTradeNo; + } + if ($refundOutTradeNo !== '') { + $refundPayload['out_trade_no'] = $refundOutTradeNo; + } + + $signedRefundPayload = $this->buildSignedPayload($refundPayload, $merchantPrivateKey); + $refundResponse = $this->callJsonEndpoint('refund', $signedRefundPayload, '/api/pay/refund'); + $refundVerify = $this->verifySignedResponse($refundResponse, $platformPublicKey); + $refundPassed = $this->classifySimpleSuccess($refundResponse, $refundVerify['passed'], [ + 'out_refund_no' => $outRefundNo, + ]); + $allPassed = $allPassed && $refundPassed; + $this->writeSimpleAttempt($output, 'refund', $signedRefundPayload, $refundResponse, $refundVerify, $refundPassed, [ + 'refund_no', + 'out_refund_no', + 'trade_no', + 'money', + 'reducemoney', + ]); + + if ($refundPassed) { + $refundQueryPayload = $this->buildSignedPayload([ + 'pid' => (int) $merchant->id, + 'out_refund_no' => $outRefundNo, + ], $merchantPrivateKey); + $refundQueryResponse = $this->callJsonEndpoint('refundQuery', $refundQueryPayload, '/api/pay/refundquery'); + $refundQueryVerify = $this->verifySignedResponse($refundQueryResponse, $platformPublicKey); + $refundQueryPassed = $this->classifySimpleSuccess($refundQueryResponse, $refundQueryVerify['passed'], [ + 'out_refund_no' => $outRefundNo, + ]); + $allPassed = $allPassed && $refundQueryPassed; + $this->writeSimpleAttempt($output, 'refundQuery', $refundQueryPayload, $refundQueryResponse, $refundQueryVerify, $refundQueryPassed, [ + 'refund_no', + 'out_refund_no', + 'trade_no', + 'status', + 'money', + 'reducemoney', + ]); + } + } + + return $allPassed ? self::SUCCESS : self::FAILURE; + } catch (\Throwable $e) { + $output->writeln('[失败] ' . $this->formatThrowable($e)); + + return self::FAILURE; + } + } + + /** + * 确保烟雾测试依赖可解析。 + * + * @return void + */ + private function ensureDependencies(): void + { + $this->resolve(EpayV2Controller::class); + $this->resolve(EpaySignerManager::class); + $this->resolve(MerchantRepository::class); + $this->resolve(MerchantApiCredentialRepository::class); + $this->resolve(PaymentTypeRepository::class); + $this->resolve(PaymentPollGroupBindRepository::class); + $this->resolve(PaymentPollGroupRepository::class); + $this->resolve(PaymentPollGroupChannelRepository::class); + $this->resolve(PaymentChannelRepository::class); + $this->resolve(BizOrderRepository::class); + $this->resolve(PayOrderRepository::class); + } + + /** + * 发现可用于测试的商户、凭证和路由上下文。 + * + * @param int $merchantIdOption 商户 ID 选项 + * @param string $merchantNoOption 商户编号选项 + * @param string $typeCode 支付方式编码 + * @return array 上下文数据 + * @throws CommandException + */ + private function discoverContext(int $merchantIdOption, string $merchantNoOption, string $typeCode): array + { + /** @var PaymentTypeRepository $paymentTypeRepository */ + $paymentTypeRepository = $this->resolve(PaymentTypeRepository::class); + $paymentType = $paymentTypeRepository->findByCode($typeCode); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('未找到可用的支付方式: ' . $typeCode); + } + + $merchant = $this->pickMerchant($merchantIdOption, $merchantNoOption); + $credential = $this->findMerchantCredential((int) $merchant->id); + if (!$credential) { + throw new CommandException('商户未开通有效 API 凭证: ' . $merchant->merchant_no); + } + if (trim((string) ($credential->merchant_public_key ?? '')) === '') { + throw new CommandException('商户未配置 RSA 公钥: ' . $merchant->merchant_no); + } + + $route = $this->buildRouteSnapshot((int) $merchant->group_id, (int) $paymentType->id); + if ($route === null) { + throw new CommandException('商户未配置可用路由: ' . $merchant->merchant_no); + } + + return [ + 'merchant' => $merchant, + 'credential' => $credential, + 'payment_type' => $paymentType, + 'route' => $route, + ]; + } + + /** + * 挑选可用商户。 + * + * @param int $merchantIdOption 商户 ID 选项 + * @param string $merchantNoOption 商户编号选项 + * @return Merchant 商户记录 + * @throws CommandException + */ + private function pickMerchant(int $merchantIdOption, string $merchantNoOption): Merchant + { + /** @var MerchantRepository $merchantRepository */ + $merchantRepository = $this->resolve(MerchantRepository::class); + + if ($merchantIdOption > 0) { + $merchant = $merchantRepository->find($merchantIdOption); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('指定商户不存在或未启用: ' . $merchantIdOption); + } + + if ($merchantNoOption !== '' && (string) $merchant->merchant_no !== $merchantNoOption) { + throw new CommandException('商户ID与商户号不匹配。'); + } + + return $merchant; + } + + if ($merchantNoOption !== '') { + $merchant = $merchantRepository->findByMerchantNo($merchantNoOption); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('指定商户不存在或未启用: ' . $merchantNoOption); + } + + return $merchant; + } + + $merchant = $merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name', 'group_id', 'status'])->first(); + if (!$merchant) { + throw new CommandException('未找到启用中的真实商户。'); + } + + return $merchant; + } + + /** + * 查询商户凭证。 + * + * @param int $merchantId 商户ID + * @return MerchantApiCredential|null 商户 API 凭证 + */ + private function findMerchantCredential(int $merchantId): ?MerchantApiCredential + { + /** @var MerchantApiCredentialRepository $repository */ + $repository = $this->resolve(MerchantApiCredentialRepository::class); + $credential = $repository->findByMerchantId($merchantId); + if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) { + return null; + } + + return $credential; + } + + /** + * 构建路由快照。 + * + * @param int $merchantGroupId 商户分组ID + * @param int $payTypeId 支付类型ID + * @return array|null 路由快照 + */ + private function buildRouteSnapshot(int $merchantGroupId, int $payTypeId): ?array + { + /** @var PaymentPollGroupBindRepository $bindRepository */ + $bindRepository = $this->resolve(PaymentPollGroupBindRepository::class); + /** @var PaymentPollGroupRepository $pollGroupRepository */ + $pollGroupRepository = $this->resolve(PaymentPollGroupRepository::class); + /** @var PaymentPollGroupChannelRepository $pollGroupChannelRepository */ + $pollGroupChannelRepository = $this->resolve(PaymentPollGroupChannelRepository::class); + /** @var PaymentChannelRepository $channelRepository */ + $channelRepository = $this->resolve(PaymentChannelRepository::class); + + $bind = $bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId); + if (!$bind) { + return null; + } + + $pollGroup = $pollGroupRepository->find((int) $bind->poll_group_id); + if (!$pollGroup || (int) $pollGroup->status !== CommonConstant::STATUS_ENABLED) { + return null; + } + + $candidateRows = $pollGroupChannelRepository->listByPollGroupId((int) $pollGroup->id); + if ($candidateRows->isEmpty()) { + return null; + } + + $channelIds = $candidateRows->pluck('channel_id')->all(); + $channels = $channelRepository->query() + ->whereIn('id', $channelIds) + ->where('status', 1) + ->get() + ->keyBy('id'); + + $candidates = []; + foreach ($candidateRows as $row) { + $channel = $channels->get((int) $row->channel_id); + if (!$channel) { + continue; + } + + if ((int) $channel->pay_type_id !== $payTypeId) { + continue; + } + + $candidates[] = [ + 'channel' => $channel, + 'poll_group_channel' => $row, + ]; + } + + if ($candidates === []) { + return null; + } + + return [ + 'bind' => $bind, + 'poll_group' => $pollGroup, + 'candidates' => $candidates, + ]; + } + + /** + * 调用 V2 JSON 接口。 + * + * @param string $action 控制器动作 + * @param array $payload 请求载荷 + * @param string $path 请求路径 + * @return array 响应数据 + */ + private function callJsonEndpoint(string $action, array $payload, string $path): array + { + /** @var EpayV2Controller $controller */ + $controller = $this->resolve(EpayV2Controller::class); + $request = $this->buildRequest($payload, $path); + $response = match ($action) { + 'create' => $controller->create($request), + 'query' => $controller->query($request), + 'refund' => $controller->refund($request), + 'refundQuery' => $controller->refundQuery($request), + 'close' => $controller->close($request), + 'merchantInfo' => $controller->merchantInfo($request), + 'merchantOrders' => $controller->merchantOrders($request), + default => throw new CommandException('不支持的 V2 测试动作: ' . $action), + }; + + return $this->decodeResponse($response->rawBody()); + } + + /** + * 构建签名后的请求载荷。 + * + * @param array $payload 原始载荷 + * @param string $privateKey 商户私钥 + * @return array 签名后的载荷 + */ + private function buildSignedPayload(array $payload, string $privateKey): array + { + /** @var EpaySignerManager $signerManager */ + $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); + + return $payload; + } + + /** + * 校验响应签名和时间戳。 + * + * @param array $responseData 响应数据 + * @param string $platformPublicKey 平台公钥 + * @return array{passed: bool, message: string} + */ + private function verifySignedResponse(array $responseData, string $platformPublicKey): array + { + $sign = trim((string) ($responseData['sign'] ?? '')); + if ($sign === '') { + return ['passed' => false, 'message' => '响应缺少 sign']; + } + + $timestamp = (int) ($responseData['timestamp'] ?? 0); + if ($timestamp <= 0) { + return ['passed' => false, 'message' => '响应缺少 timestamp']; + } + + $ttl = (int) config('epay.v2.timestamp_ttl', 300); + if (abs(time() - $timestamp) > $ttl) { + return ['passed' => false, 'message' => '响应 timestamp 超出校验窗口']; + } + + $signType = (string) ($responseData['sign_type'] ?? ''); + if ($signType === '') { + return ['passed' => false, 'message' => '响应缺少 sign_type']; + } + + /** @var EpaySignerManager $signerManager */ + $signerManager = $this->resolve(EpaySignerManager::class); + $verifyPayload = $responseData; + unset($verifyPayload['sign'], $verifyPayload['sign_type']); + + $passed = $signerManager->verify($verifyPayload, $signType, $sign, $platformPublicKey); + + return [ + 'passed' => $passed, + 'message' => $passed ? '验签通过' : '响应验签失败', + ]; + } + + /** + * 判定创建订单是否成功。 + * + * @param array $responseData 响应数据 + * @param array $orderSnapshot 订单快照 + * @param bool $verified 是否通过响应验签 + * @return bool 是否通过 + */ + private function classifyCreateAttempt(array $responseData, array $orderSnapshot, bool $verified): bool + { + $payOrder = $orderSnapshot['pay_order'] ?? null; + $bizOrder = $orderSnapshot['biz_order'] ?? null; + + return (int) ($responseData['code'] ?? -1) === 0 + && $verified + && (string) ($responseData['trade_no'] ?? '') !== '' + && $payOrder + && $bizOrder; + } + + /** + * 判定简单成功响应。 + * + * @param array $responseData 响应数据 + * @param bool $verified 是否通过响应验签 + * @param array $expectedFields 期望字段 + * @return bool 是否通过 + */ + private function classifySimpleSuccess(array $responseData, bool $verified, array $expectedFields = []): bool + { + if ((int) ($responseData['code'] ?? -1) !== 0 || !$verified) { + return false; + } + + foreach ($expectedFields as $key => $expectedValue) { + if ((string) ($responseData[$key] ?? '') !== (string) $expectedValue) { + return false; + } + } + + return true; + } + + /** + * 输出创建订单结果。 + * + * @param OutputInterface $output 输出对象 + * @param array $payload 请求载荷 + * @param array $responseData 响应数据 + * @param array $orderSnapshot 订单快照 + * @param array $verifyResult 响应验签结果 + * @param bool $passed 是否通过 + * @return void + */ + private function writeCreateAttempt( + OutputInterface $output, + array $payload, + array $responseData, + array $orderSnapshot, + array $verifyResult, + bool $passed + ): void { + $label = $passed ? '[通过]' : '[失败]'; + $payOrder = $orderSnapshot['pay_order'] ?? []; + $bizOrder = $orderSnapshot['biz_order'] ?? []; + $channel = $orderSnapshot['channel'] ?? []; + $paymentType = $orderSnapshot['payment_type'] ?? []; + + $output->writeln(sprintf('%s create - out_trade_no=%s', $label, (string) $payload['out_trade_no'])); + $output->writeln(sprintf( + ' 请求: pid=%d type=%s method=%s money=%s device=%s', + (int) $payload['pid'], + (string) $payload['type'], + (string) $payload['method'], + (string) $payload['money'], + (string) ($payload['device'] ?? '') + )); + $output->writeln(sprintf( + ' 响应: code=%s msg=%s sign=%s', + (string) ($responseData['code'] ?? ''), + (string) ($responseData['msg'] ?? ''), + (string) $verifyResult['message'] + )); + $output->writeln(sprintf( + ' 返回: trade_no=%s pay_type=%s', + (string) ($responseData['trade_no'] ?? ''), + (string) ($responseData['pay_type'] ?? '') + )); + $payInfoSummary = $this->summarizePayInfo((string) ($responseData['pay_type'] ?? ''), $responseData['pay_info'] ?? null); + if ($payInfoSummary !== '') { + $output->writeln(' pay_info: ' . $payInfoSummary); + } + + if (!$bizOrder || !$payOrder) { + $output->writeln(' 订单: 未创建或未查到业务单/支付单'); + return; + } + + $output->writeln(sprintf( + ' 业务单: biz_no=%s status=%s active_pay_no=%s attempt_count=%d', + (string) ($bizOrder['biz_no'] ?? ''), + $this->orderStatusLabel((int) ($bizOrder['status'] ?? 0)), + (string) ($bizOrder['active_pay_no'] ?? ''), + (int) ($bizOrder['attempt_count'] ?? 0) + )); + $output->writeln(sprintf( + ' 支付单: pay_no=%s status=%s channel_id=%d channel=%s plugin=%s pay_type=%s', + (string) ($payOrder['pay_no'] ?? ''), + $this->orderStatusLabel((int) ($payOrder['status'] ?? 0)), + (int) ($payOrder['channel_id'] ?? 0), + (string) ($channel['name'] ?? ''), + (string) ($payOrder['plugin_code'] ?? ''), + (string) ($paymentType['code'] ?? '') + )); + } + + /** + * 输出简单接口结果。 + * + * @param OutputInterface $output 输出对象 + * @param string $name 名称 + * @param array $payload 请求载荷 + * @param array $responseData 响应数据 + * @param array $verifyResult 验签结果 + * @param bool $passed 是否通过 + * @param array $fields 需要额外输出的字段 + * @return void + */ + private function writeSimpleAttempt( + OutputInterface $output, + string $name, + array $payload, + array $responseData, + array $verifyResult, + bool $passed, + array $fields + ): void { + $label = $passed ? '[通过]' : '[失败]'; + $output->writeln(sprintf( + '%s %s - pid=%d code=%s msg=%s sign=%s', + $label, + $name, + (int) ($payload['pid'] ?? 0), + (string) ($responseData['code'] ?? ''), + (string) ($responseData['msg'] ?? ''), + (string) $verifyResult['message'] + )); + + foreach ($fields as $field) { + if (!array_key_exists($field, $responseData)) { + continue; + } + + $output->writeln(sprintf(' 返回: %s=%s', $field, $this->stringifyValue($responseData[$field]))); + } + } + + /** + * 归纳 pay_info 展示内容。 + * + * @param string $payType pay_type + * @param mixed $payInfo pay_info + * @return string 展示文本 + */ + private function summarizePayInfo(string $payType, mixed $payInfo): string + { + if (is_string($payInfo)) { + return $this->limitString($this->normalizeWhitespace($payInfo), 160); + } + + if (!is_array($payInfo)) { + return $this->stringifyValue($payInfo); + } + + $summary = ['pay_type' => $payType]; + foreach (['payurl', 'qrcode', 'urlscheme', 'html', 'app_id', 'nonce_str', 'package'] as $key) { + if (!array_key_exists($key, $payInfo)) { + continue; + } + + $summary[$key] = $key === 'html' + ? $this->limitString($this->normalizeWhitespace((string) $payInfo[$key]), 160) + : $this->stringifyValue($payInfo[$key]); + } + + if ($summary === ['pay_type' => $payType]) { + $summary['keys'] = array_values(array_map('strval', array_keys($payInfo))); + } + + return $this->formatJson($summary); + } + + /** + * 校验提供的私钥与商户后台公钥是否匹配。 + * + * @param string $merchantPrivateKey 商户私钥 + * @param string $merchantPublicKey 商户公钥 + * @return void + * @throws CommandException + */ + private function ensureKeyPairMatches(string $merchantPrivateKey, string $merchantPublicKey): void + { + /** @var EpaySignerManager $signerManager */ + $signerManager = $this->resolve(EpaySignerManager::class); + $probePayload = [ + '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)) { + throw new CommandException('提供的商户私钥与后台配置的商户公钥不匹配。'); + } + } + + /** + * 加载订单快照。 + * + * @param int $merchantId 商户ID + * @param string $merchantOrderNo 商户订单号 + * @return array 订单快照 + */ + private function loadOrderSnapshot(int $merchantId, string $merchantOrderNo): array + { + /** @var BizOrderRepository $bizOrderRepository */ + $bizOrderRepository = $this->resolve(BizOrderRepository::class); + /** @var PayOrderRepository $payOrderRepository */ + $payOrderRepository = $this->resolve(PayOrderRepository::class); + /** @var PaymentChannelRepository $channelRepository */ + $channelRepository = $this->resolve(PaymentChannelRepository::class); + /** @var PaymentTypeRepository $typeRepository */ + $typeRepository = $this->resolve(PaymentTypeRepository::class); + + $bizOrder = $bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo); + $payOrder = $bizOrder ? $payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no) : null; + $channel = $payOrder ? $channelRepository->find((int) $payOrder->channel_id) : null; + $paymentType = $payOrder ? $typeRepository->find((int) $payOrder->pay_type_id) : null; + + return [ + 'biz_order' => $bizOrder ? $bizOrder->toArray() : null, + 'pay_order' => $payOrder ? $payOrder->toArray() : null, + 'channel' => $channel ? $channel->toArray() : null, + 'payment_type' => $paymentType ? $paymentType->toArray() : null, + ]; + } + + /** + * 输出路由快照。 + * + * @param OutputInterface $output 输出对象 + * @param array $route 路由快照 + * @return void + */ + private function writeRouteSnapshot(OutputInterface $output, array $route): void + { + /** @var PaymentPollGroup $pollGroup */ + $pollGroup = $route['poll_group']; + $candidates = $route['candidates']; + + $output->writeln(sprintf( + '路由: group_id=%d group_name=%s mode=%s', + (int) $pollGroup->id, + (string) $pollGroup->group_name, + $this->routeModeLabel((int) $pollGroup->route_mode) + )); + $output->writeln(sprintf(' 候选通道: %d 个', count($candidates))); + foreach ($candidates as $item) { + /** @var PaymentChannel $channel */ + $channel = $item['channel']; + $pollGroupChannel = $item['poll_group_channel']; + $output->writeln(sprintf( + ' - channel_id=%d name=%s default=%s sort_no=%d weight=%d mode=%s pay_type_id=%d plugin=%s', + (int) $channel->id, + (string) $channel->name, + (int) $pollGroupChannel->is_default === 1 ? 'yes' : 'no', + (int) $pollGroupChannel->sort_no, + (int) $pollGroupChannel->weight, + $this->channelModeLabel((int) $channel->channel_mode), + (int) $channel->pay_type_id, + (string) $channel->plugin_code + )); + } + } + + /** + * 获取路由模式名称。 + * + * @param int $routeMode 路由模式 + * @return string 路由模式名称 + */ + private function routeModeLabel(int $routeMode): string + { + return RouteConstant::routeModeMap()[$routeMode] ?? '未知'; + } + + /** + * 获取通道模式名称。 + * + * @param int $channelMode 通道模式 + * @return string 通道模式名称 + */ + private function channelModeLabel(int $channelMode): string + { + return RouteConstant::channelModeMap()[$channelMode] ?? '未知'; + } + + /** + * 获取订单状态名称。 + * + * @param int $status 状态 + * @return string 订单状态名称 + */ + private function orderStatusLabel(int $status): string + { + return TradeConstant::orderStatusMap()[$status] ?? '未知'; + } + + /** + * 生成商户订单号。 + * + * @param string $base 基础订单号 + * @return string 商户订单号 + */ + private function buildMerchantOrderNo(string $base): string + { + $base = trim($base); + if ($base !== '') { + return substr($base, 0, 64); + } + + return 'EPAY-V2-' . FormatHelper::timestamp(time(), 'YmdHis') . random_int(1000, 9999); + } + + /** + * 生成商户退款单号。 + * + * @param string $base 基础退款单号 + * @return string 商户退款单号 + */ + private function buildRefundNo(string $base): string + { + $base = trim($base); + if ($base !== '') { + return substr($base, 0, 64); + } + + return 'EPAY-RFD-' . FormatHelper::timestamp(time(), 'YmdHis') . random_int(1000, 9999); + } + + /** + * 构建模拟请求对象。 + * + * @param array $payload 请求载荷 + * @param string $path 请求路径 + * @return Request 请求对象 + */ + private function buildRequest(array $payload, string $path): Request + { + $body = http_build_query($payload, '', '&', PHP_QUERY_RFC1738); + $siteUrl = $this->resolveSiteUrl(); + $host = parse_url($siteUrl, PHP_URL_HOST) ?: 'localhost'; + $port = parse_url($siteUrl, PHP_URL_PORT); + $hostHeader = $port ? sprintf('%s:%s', $host, $port) : $host; + $path = '/' . ltrim($path, '/'); + + $rawRequest = implode("\r\n", [ + 'POST ' . $path . ' HTTP/1.1', + 'Host: ' . $hostHeader, + 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8', + 'Content-Length: ' . strlen($body), + 'Connection: close', + '', + $body, + ]); + + return new Request($rawRequest); + } + + /** + * 解析站点地址。 + * + * @return string 站点地址 + */ + private function resolveSiteUrl(): string + { + $siteUrl = trim((string) sys_config('site_url')); + return $siteUrl !== '' ? rtrim($siteUrl, '/') : 'http://localhost:8787'; + } + + /** + * 解析商户私钥。 + * + * @param InputInterface $input 命令输入 + * @return string 私钥 + * @throws CommandException + */ + private function resolveMerchantPrivateKey(InputInterface $input, int $merchantId = 0): string + { + $inline = trim($this->optionString($input, 'merchant-private-key', '')); + if ($inline !== '') { + return $inline; + } + + $file = trim($this->optionString($input, 'merchant-private-key-file', '')); + if ($file !== '') { + if (!is_file($file)) { + throw new CommandException('商户私钥文件不存在: ' . $file); + } + + return trim((string) file_get_contents($file)); + } + + $envFile = trim((string) env('EPAY_V2_TEST_MERCHANT_PRIVATE_KEY_FILE', '')); + if ($envFile !== '') { + if (!is_file($envFile)) { + throw new CommandException('环境变量指定的商户私钥文件不存在: ' . $envFile); + } + + return trim((string) file_get_contents($envFile)); + } + + $envKey = trim((string) env('EPAY_V2_TEST_MERCHANT_PRIVATE_KEY', '')); + if ($envKey !== '') { + return $envKey; + } + + if ($merchantId > 0) { + $defaultFile = base_path(false) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'epay' . DIRECTORY_SEPARATOR . sprintf('merchant-%d-private.pem', $merchantId); + if (is_file($defaultFile)) { + return trim((string) file_get_contents($defaultFile)); + } + } + + throw new CommandException('缺少商户 RSA 私钥,请先执行 epay:v2-bootstrap,或通过 --merchant-private-key / --merchant-private-key-file / EPAY_V2_TEST_MERCHANT_PRIVATE_KEY 提供。'); + } + + /** + * 解析平台公钥。 + * + * @return string 平台公钥 + * @throws CommandException + */ + private function resolvePlatformPublicKey(): string + { + $publicKey = trim((string) config('epay.v2.platform_public_key', '')); + if ($publicKey === '') { + throw new CommandException('平台 RSA 公钥未配置,无法校验 V2 响应签名。'); + } + + return $publicKey; + } + + /** + * 归一化金额字符串。 + * + * @param string $money 金额 + * @return string 金额字符串 + * @throws CommandException + */ + private function normalizeMoney(string $money): string + { + $money = trim($money); + if ($money === '') { + return '1.00'; + } + + if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) { + throw new CommandException('money 参数不合法: ' . $money); + } + + return number_format((float) $money, 2, '.', ''); + } + + /** + * 归一化设备类型。 + * + * @param string $device 设备类型 + * @return string 设备类型 + */ + private function normalizeDevice(string $device): string + { + $device = strtolower(trim($device)); + return $device !== '' ? $device : 'pc'; + } + + /** + * 归一化 method。 + * + * @param string $method 接口方式 + * @return string 接口方式 + * @throws CommandException + */ + private function normalizeMethod(string $method): string + { + $method = strtolower(trim($method)); + $allowed = ['web', 'jump', 'jsapi', 'app', 'scan', 'applet']; + if (!in_array($method, $allowed, true)) { + throw new CommandException('method 参数不合法: ' . $method); + } + + return $method; + } + + /** + * 解析响应体。 + * + * @param string $body 响应体 + * @return array 解析结果 + */ + private function decodeResponse(string $body): array + { + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : ['raw' => $body]; + } + + /** + * 将值转为字符串。 + * + * @param mixed $value 可转为字符串的值 + * @return string 可展示字符串 + */ + private function stringifyValue(mixed $value): string + { + if ($value === null) { + return ''; + } + if (is_string($value)) { + return trim($value); + } + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + if (is_array($value) || is_object($value)) { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $json !== false ? $json : ''; + } + + return (string) $value; + } + + /** + * 限制字符串长度。 + * + * @param string $value 待截断文本 + * @param int $length 最大长度 + * @return string 截断后的字符串 + */ + private function limitString(string $value, int $length): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + return strlen($value) <= $length ? $value : substr($value, 0, $length) . '...'; + } + + /** + * 归一化空白字符。 + * + * @param string $value 待归一化文本 + * @return string 归一化结果 + */ + private function normalizeWhitespace(string $value): string + { + return preg_replace('/\s+/', ' ', trim($value)) ?: ''; + } + + /** + * 格式化 JSON。 + * + * @param mixed $value 可编码为 JSON 的值 + * @return string JSON 文本 + */ + private function formatJson(mixed $value): string + { + return FormatHelper::json($value); + } + + /** + * 格式化异常文本。 + * + * @param Throwable $e 异常 + * @return string 文本结果 + */ + private function formatThrowable(\Throwable $e): string + { + $data = method_exists($e, 'getData') ? $e->getData() : []; + $suffix = is_array($data) && $data !== [] ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; + return $e::class . ':' . $e->getMessage() . $suffix; + } + + /** + * 读取字符串选项。 + * + * @param InputInterface $input 命令输入 + * @param string $name 选项名称 + * @param string $default 默认值 + * @return string 选项值 + */ + private function optionString(InputInterface $input, string $name, string $default = ''): string + { + $value = $input->getOption($name); + return $value === null || $value === false ? $default : (is_string($value) ? $value : (string) $value); + } + + /** + * 读取整数选项。 + * + * @param InputInterface $input 命令输入 + * @param string $name 选项名称 + * @param int $default 默认值 + * @return int 选项值 + */ + private function optionInt(InputInterface $input, string $name, int $default = 0): int + { + $value = $input->getOption($name); + return is_numeric($value) ? (int) $value : $default; + } + + /** + * 读取布尔选项。 + * + * @param InputInterface $input 命令输入 + * @param string $name 选项名称 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function optionBool(InputInterface $input, string $name, bool $default = false): bool + { + $value = $input->getOption($name); + if ($value === null || $value === '') { + return $default; + } + + $filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + + return $filtered === null ? $default : $filtered; + } + + /** + * 从容器中解析指定类实例。 + * + * @param string $class 类名 + * @return object 对象实例 + * @throws CommandException + */ + private function resolve(string $class): object + { + try { + $instance = container_make($class, []); + } catch (\Throwable $e) { + throw new CommandException("无法解析 {$class}。", 0, $e); + } + + if (!is_object($instance)) { + throw new CommandException("解析后的 {$class} 不是对象。"); + } + + return $instance; + } +} diff --git a/app/command/EpayV2Bootstrap.php b/app/command/EpayV2Bootstrap.php new file mode 100644 index 0000000..8953a3f --- /dev/null +++ b/app/command/EpayV2Bootstrap.php @@ -0,0 +1,490 @@ +setDescription('为开发环境生成平台 RSA 密钥、测试商户 RSA 密钥,并写回商户公钥。') + ->addOption('merchant-id', null, InputOption::VALUE_OPTIONAL, '指定商户 ID') + ->addOption('merchant-no', null, InputOption::VALUE_OPTIONAL, '指定商户号') + ->addOption('force-platform', null, InputOption::VALUE_NONE, '强制覆盖已存在的平台密钥文件') + ->addOption('force-merchant', null, InputOption::VALUE_NONE, '强制覆盖已存在的商户密钥文件和商户 RSA 公钥'); + } + + /** + * 执行初始化。 + * + * @param InputInterface $input 命令输入 + * @param OutputInterface $output 输出对象 + * @return int 命令退出码 + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $merchant = $this->pickMerchant( + $this->optionInt($input, 'merchant-id', 0), + trim($this->optionString($input, 'merchant-no', '')) + ); + $directory = base_path(false) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'epay'; + $forcePlatform = $this->optionBool($input, 'force-platform', false); + $forceMerchant = $this->optionBool($input, 'force-merchant', false); + + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new CommandException('创建密钥目录失败: ' . $directory); + } + + $platformPaths = $this->bootstrapPlatformKeys($directory, $forcePlatform); + $merchantPaths = $this->bootstrapMerchantKeys($merchant, $directory, $forceMerchant); + + $output->writeln('[完成] ePay V2 开发联调密钥已就绪'); + $output->writeln(sprintf( + '商户: id=%d no=%s name=%s', + (int) $merchant->id, + (string) $merchant->merchant_no, + (string) $merchant->merchant_name + )); + $output->writeln('平台私钥: ' . $platformPaths['private']); + $output->writeln('平台公钥: ' . $platformPaths['public']); + $output->writeln('商户私钥: ' . $merchantPaths['private']); + $output->writeln('商户公钥: ' . $merchantPaths['public']); + $output->writeln('商户公钥预览: ' . FormatHelper::maskCredentialValue($merchantPaths['public_key'], false)); + $output->writeln('下一步可直接运行: php webman epay:v2-api --live --merchant-id=' . (int) $merchant->id); + + return self::SUCCESS; + } catch (\Throwable $e) { + $output->writeln('[失败] ' . $this->formatThrowable($e)); + + return self::FAILURE; + } + } + + /** + * 生成或复用平台 RSA 密钥。 + * + * @param string $directory 密钥目录 + * @param bool $force 是否强制覆盖 + * @return array{private: string, public: string} + * @throws CommandException + */ + private function bootstrapPlatformKeys(string $directory, bool $force): array + { + $privatePath = $directory . DIRECTORY_SEPARATOR . 'platform-private.pem'; + $publicPath = $directory . DIRECTORY_SEPARATOR . 'platform-public.pem'; + + if (!$force && is_file($privatePath) && is_file($publicPath)) { + return ['private' => $privatePath, 'public' => $publicPath]; + } + + $pair = $this->generateKeyPair(); + $this->writePemFile($privatePath, $pair['private_key']); + $this->writePemFile($publicPath, $pair['public_key']); + + return ['private' => $privatePath, 'public' => $publicPath]; + } + + /** + * 生成或复用商户 RSA 密钥,并写回商户公钥。 + * + * @param Merchant $merchant 商户 + * @param string $directory 密钥目录 + * @param bool $force 是否强制覆盖 + * @return array{private: string, public: string, public_key: string} + * @throws CommandException + */ + private function bootstrapMerchantKeys(Merchant $merchant, string $directory, bool $force): array + { + /** @var MerchantApiCredentialRepository $credentialRepository */ + $credentialRepository = $this->resolve(MerchantApiCredentialRepository::class); + $credential = $credentialRepository->findByMerchantId((int) $merchant->id); + $privatePath = $directory . DIRECTORY_SEPARATOR . sprintf('merchant-%d-private.pem', (int) $merchant->id); + $publicPath = $directory . DIRECTORY_SEPARATOR . sprintf('merchant-%d-public.pem', (int) $merchant->id); + + if (!$force && is_file($privatePath) && is_file($publicPath) && trim((string) ($credential?->merchant_public_key ?? '')) !== '') { + return [ + 'private' => $privatePath, + 'public' => $publicPath, + 'public_key' => trim((string) ($credential?->merchant_public_key ?? '')), + ]; + } + + $pair = $this->generateKeyPair(); + $this->writePemFile($privatePath, $pair['private_key']); + $this->writePemFile($publicPath, $pair['public_key']); + + $credentialRepository->updateOrCreate( + ['merchant_id' => (int) $merchant->id], + [ + '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'], + ] + ); + + return [ + 'private' => $privatePath, + 'public' => $publicPath, + 'public_key' => $pair['public_key'], + ]; + } + + /** + * 选择目标商户。 + * + * @param int $merchantId 商户ID + * @param string $merchantNo 商户号 + * @return Merchant 商户 + * @throws CommandException + */ + private function pickMerchant(int $merchantId, string $merchantNo): Merchant + { + /** @var MerchantRepository $merchantRepository */ + $merchantRepository = $this->resolve(MerchantRepository::class); + + if ($merchantId > 0) { + $merchant = $merchantRepository->find($merchantId); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('指定商户不存在或未启用: ' . $merchantId); + } + + return $merchant; + } + + if ($merchantNo !== '') { + $merchant = $merchantRepository->findByMerchantNo($merchantNo); + if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { + throw new CommandException('指定商户不存在或未启用: ' . $merchantNo); + } + + return $merchant; + } + + $merchant = $merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name', 'status'])->first(); + if (!$merchant) { + throw new CommandException('未找到启用中的商户。'); + } + + return $merchant; + } + + /** + * 生成 RSA 密钥对。 + * + * @return array{private_key: string, public_key: string} + * @throws CommandException + */ + private function generateKeyPair(): array + { + $this->ensureOpenSslConfig(); + $pair = $this->generateKeyPairWithOpenSsl(); + if ($pair !== null) { + return $pair; + } + + $pair = $this->generateKeyPairWithSshKeygen(); + if ($pair !== null) { + return $pair; + } + + if (PHP_OS_FAMILY === 'Windows') { + $pair = $this->generateKeyPairWithPowerShell(); + if ($pair !== null) { + return $pair; + } + } + + throw new CommandException('生成 RSA 密钥对失败'); + } + + /** + * 尝试通过 OpenSSL 扩展生成密钥对。 + * + * @return array{private_key: string, public_key: string}|null + */ + private function generateKeyPairWithOpenSsl(): ?array + { + while (openssl_error_string()) { + } + + $resource = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + if ($resource === false) { + return null; + } + + $privateKey = ''; + if (!openssl_pkey_export($resource, $privateKey) || trim($privateKey) === '') { + return null; + } + + $details = openssl_pkey_get_details($resource); + $publicKey = trim((string) ($details['key'] ?? '')); + if ($publicKey === '') { + return null; + } + + return [ + 'private_key' => trim($privateKey), + 'public_key' => $publicKey, + ]; + } + + /** + * 通过系统自带 ssh-keygen 生成 PEM 私钥和 PKCS8 公钥。 + * + * @return array{private_key: string, public_key: string}|null + */ + private function generateKeyPairWithSshKeygen(): ?array + { + $runtimeDir = base_path(false) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'epay'; + if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0777, true) && !is_dir($runtimeDir)) { + return null; + } + + $basePath = $runtimeDir . DIRECTORY_SEPARATOR . 'sshkeygen-' . bin2hex(random_bytes(6)); + $privatePath = str_replace('\\', '/', $basePath); + $publicPath = $privatePath . '.pub'; + + try { + $generateCommand = sprintf( + 'ssh-keygen -q -t rsa -b 2048 -m PEM -N "" -f %s 2>&1', + escapeshellarg($privatePath) + ); + shell_exec($generateCommand); + + if (!is_file($basePath) || !is_file($basePath . '.pub')) { + return null; + } + + $privateKey = trim((string) file_get_contents($basePath)); + $exportCommand = sprintf( + 'ssh-keygen -e -m PKCS8 -f %s 2>&1', + escapeshellarg($publicPath) + ); + $publicKey = trim((string) shell_exec($exportCommand)); + if ($privateKey === '' || $publicKey === '' || !str_contains($publicKey, 'BEGIN PUBLIC KEY')) { + return null; + } + + return [ + 'private_key' => $privateKey, + 'public_key' => $publicKey, + ]; + } finally { + @unlink($basePath); + @unlink($basePath . '.pub'); + } + } + + /** + * 在 Windows 下通过 PowerShell/.NET 生成密钥对。 + * + * @return array{private_key: string, public_key: string}|null + */ + private function generateKeyPairWithPowerShell(): ?array + { + $script = <<<'POWERSHELL' +$rsa = [System.Security.Cryptography.RSA]::Create(2048) +function Convert-ToPem([string]$Label, [byte[]]$Bytes) { + $base64 = [System.Convert]::ToBase64String($Bytes) + $lines = [System.Text.RegularExpressions.Regex]::Matches($base64, '.{1,64}') | ForEach-Object { $_.Value } + return "-----BEGIN $Label-----`n$($lines -join "`n")`n-----END $Label-----" +} +$result = [ordered]@{ + private_key = Convert-ToPem 'PRIVATE KEY' ($rsa.ExportPkcs8PrivateKey()) + public_key = Convert-ToPem 'PUBLIC KEY' ($rsa.ExportSubjectPublicKeyInfo()) +} +$result | ConvertTo-Json -Compress +POWERSHELL; + + $encodedScript = iconv('UTF-8', 'UTF-16LE', $script); + if ($encodedScript === false) { + return null; + } + + $command = 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' . base64_encode($encodedScript) . ' 2>&1'; + $output = shell_exec($command); + if (!is_string($output) || trim($output) === '') { + return null; + } + + $decoded = json_decode(trim($output), true); + if (!is_array($decoded)) { + return null; + } + + $privateKey = trim((string) ($decoded['private_key'] ?? '')); + $publicKey = trim((string) ($decoded['public_key'] ?? '')); + if ($privateKey === '' || $publicKey === '') { + return null; + } + + return [ + 'private_key' => $privateKey, + 'public_key' => $publicKey, + ]; + } + + /** + * 为 openssl_pkey_new() 补齐配置文件路径。 + * + * 某些 Windows PHP 环境默认把 openssl.cnf 指向不存在的位置,这里优先回退到 + * 当前 PHP 目录自带的 extras/ssl/openssl.cnf。 + * + * @return void + */ + private function ensureOpenSslConfig(): void + { + $current = trim((string) getenv('OPENSSL_CONF')); + if ($current !== '' && is_file($current)) { + return; + } + + $candidates = []; + $loadedIni = php_ini_loaded_file(); + if (is_string($loadedIni) && $loadedIni !== '') { + $candidates[] = dirname($loadedIni) . DIRECTORY_SEPARATOR . 'extras' . DIRECTORY_SEPARATOR . 'ssl' . DIRECTORY_SEPARATOR . 'openssl.cnf'; + } + if (defined('PHP_BINARY')) { + $candidates[] = dirname(PHP_BINARY) . DIRECTORY_SEPARATOR . 'extras' . DIRECTORY_SEPARATOR . 'ssl' . DIRECTORY_SEPARATOR . 'openssl.cnf'; + } + $locations = openssl_get_cert_locations(); + if (!empty($locations['default_cert_file'])) { + $candidates[] = (string) $locations['default_cert_file']; + } + + foreach ($candidates as $candidate) { + if (is_string($candidate) && $candidate !== '' && is_file($candidate)) { + putenv('OPENSSL_CONF=' . $candidate); + $_ENV['OPENSSL_CONF'] = $candidate; + return; + } + } + } + + /** + * 写入 PEM 文件。 + * + * @param string $path 文件路径 + * @param string $content 文件内容 + * @return void + * @throws CommandException + */ + private function writePemFile(string $path, string $content): void + { + if (file_put_contents($path, $content . PHP_EOL) === false) { + throw new CommandException('写入密钥文件失败: ' . $path); + } + } + + /** + * 格式化异常信息。 + * + * @param Throwable $e 异常 + * @return string 文本信息 + */ + private function formatThrowable(\Throwable $e): string + { + return $e::class . ':' . $e->getMessage(); + } + + /** + * 读取字符串选项。 + * + * @param InputInterface $input 命令输入 + * @param string $name 选项名称 + * @param string $default 默认值 + * @return string 选项值 + */ + private function optionString(InputInterface $input, string $name, string $default = ''): string + { + $value = $input->getOption($name); + return $value === null || $value === false ? $default : (is_string($value) ? $value : (string) $value); + } + + /** + * 读取整数选项。 + * + * @param InputInterface $input 命令输入 + * @param string $name 选项名称 + * @param int $default 默认值 + * @return int 选项值 + */ + private function optionInt(InputInterface $input, string $name, int $default = 0): int + { + $value = $input->getOption($name); + return is_numeric($value) ? (int) $value : $default; + } + + /** + * 读取布尔选项。 + * + * @param InputInterface $input 命令输入 + * @param string $name 选项名称 + * @param bool $default 默认值 + * @return bool 布尔值 + */ + private function optionBool(InputInterface $input, string $name, bool $default = false): bool + { + $value = $input->getOption($name); + if ($value === null || $value === '') { + return $default; + } + + $filtered = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + + return $filtered === null ? $default : $filtered; + } + + /** + * 从容器解析实例。 + * + * @param string $class 类名 + * @return object 实例 + * @throws CommandException + */ + private function resolve(string $class): object + { + try { + $instance = container_make($class, []); + } catch (\Throwable $e) { + throw new CommandException("无法解析 {$class}。", 0, $e); + } + + if (!is_object($instance)) { + throw new CommandException("解析后的 {$class} 不是对象。"); + } + + return $instance; + } +} diff --git a/app/command/MpayTest.php b/app/command/MpayTest.php index 67a13b6..30fdead 100644 --- a/app/command/MpayTest.php +++ b/app/command/MpayTest.php @@ -4,6 +4,7 @@ namespace app\command; use app\common\constant\TradeConstant; use app\common\util\FormatHelper; +use app\exception\CommandException; use app\service\account\funds\MerchantAccountService; use app\service\merchant\MerchantService; use app\service\payment\order\PayOrderService; @@ -11,7 +12,6 @@ use app\service\payment\order\RefundService; use app\service\payment\settlement\SettlementService; use app\service\payment\trace\TradeTraceService; use app\repository\payment\trade\PayOrderRepository; -use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -223,7 +223,7 @@ class MpayTest extends Command } elseif ($this->envBool('MPAY_TEST_REFUND_MARK_FAIL', false)) { $service->markRefundFailed((string) $refund->refund_no, [ 'failed_at' => $this->envString('MPAY_TEST_REFUND_FAILED_AT', FormatHelper::timestamp(time())), - 'last_error' => $this->envString('MPAY_TEST_REFUND_LAST_ERROR', 'mpay smoke refund failed'), + 'last_error' => $this->envString('MPAY_TEST_REFUND_LAST_ERROR', '烟雾测试退款失败'), ]); $message .= ', 已标记失败'; } elseif ($this->envBool('MPAY_TEST_REFUND_MARK_SUCCESS', false)) { @@ -437,18 +437,18 @@ class MpayTest extends Command * * @param string $class 类名 * @return object 对象实例 - * @throws RuntimeException + * @throws CommandException */ private function resolve(string $class): object { try { $instance = container_make($class, []); } catch (\Throwable $e) { - throw new RuntimeException("无法解析 {$class}: " . $e->getMessage(), 0, $e); + throw new CommandException("无法解析 {$class}。", 0, $e); } if (!is_object($instance)) { - throw new RuntimeException("解析后的 {$class} 不是对象。"); + throw new CommandException("解析后的 {$class} 不是对象。"); } return $instance; @@ -460,12 +460,12 @@ class MpayTest extends Command * @param object $instance 实例 * @param string $method 方法名 * @return void - * @throws RuntimeException + * @throws CommandException */ private function ensureMethod(object $instance, string $method): void { if (!method_exists($instance, $method)) { - throw new RuntimeException(sprintf('未找到方法 %s::%s。', $instance::class, $method)); + throw new CommandException(sprintf('未找到方法 %s::%s。', $instance::class, $method)); } } @@ -555,8 +555,7 @@ class MpayTest extends Command { $data = method_exists($e, 'getData') ? $e->getData() : []; $suffix = $data ? ' ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; - - return $e::class . ': ' . $e->getMessage() . $suffix; + return $e::class . ':' . $e->getMessage() . $suffix; } /** diff --git a/app/command/NotifyRetry.php b/app/command/NotifyRetry.php new file mode 100644 index 0000000..f328125 --- /dev/null +++ b/app/command/NotifyRetry.php @@ -0,0 +1,52 @@ +addOption('limit', null, InputOption::VALUE_OPTIONAL, '单次最多处理多少条任务', 100); + } + + /** + * 执行重试。 + * + * @param InputInterface $input 输入 + * @param OutputInterface $output 输出 + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $limit = max(1, (int) $input->getOption('limit')); + $count = $this->merchantNotifyDispatcherService->dispatchRetryableTasks($limit); + + $output->writeln(sprintf('已处理 %d 条商户通知任务', $count)); + + return self::SUCCESS; + } +} diff --git a/app/command/SystemConfigSync.php b/app/command/SystemConfigSync.php index 61ff865..c0dca7b 100644 --- a/app/command/SystemConfigSync.php +++ b/app/command/SystemConfigSync.php @@ -5,7 +5,6 @@ namespace app\command; use app\repository\system\config\SystemConfigRepository; use app\service\system\config\SystemConfigDefinitionService; use app\service\system\config\SystemConfigRuntimeService; -use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -57,7 +56,7 @@ class SystemConfigSync extends Command } $configKey = strtolower(trim((string) ($rule['field'] ?? ''))); - if ($configKey === '') { + if ($configKey === '' || str_starts_with($configKey, '__')) { continue; } @@ -86,5 +85,3 @@ class SystemConfigSync extends Command } } - - diff --git a/app/common/base/BasePayment.php b/app/common/base/BasePayment.php index 0a09dea..bbf5460 100644 --- a/app/common/base/BasePayment.php +++ b/app/common/base/BasePayment.php @@ -18,7 +18,7 @@ use support\Log; * * 生命周期: * - 服务层会在每次动作前调用 `init($channelConfig)` 注入该通道配置。 - * - 子类可在 `init()` 中配置第三方 SDK(例如 yansongda/pay)或读取必填参数。 + * - 子类可在 `init()` 中配置第三方 SDK 或读取必填参数。 * * 约定: * - 这里的 `$channelConfig` 来源通常是 `ma_payment_plugin_conf.config`,并附带通道维度上下文。 @@ -191,7 +191,11 @@ abstract class BasePayment implements PayPluginInterface return $this->httpClient->request($method, $url, $options); } catch (GuzzleException $e) { Log::error(sprintf('[BasePayment] HTTP 请求失败: %s %s, error=%s', $method, $url, $e->getMessage())); - throw new PaymentException('渠道请求失败:' . $e->getMessage(), 402, ['method' => $method, 'url' => $url]); + throw new PaymentException('渠道请求失败', 402, [ + 'method' => $method, + 'url' => $url, + 'error' => $e->getMessage(), + ]); } } } diff --git a/app/common/constant/AuthConstant.php b/app/common/constant/AuthConstant.php index 9c28039..3b2318c 100644 --- a/app/common/constant/AuthConstant.php +++ b/app/common/constant/AuthConstant.php @@ -5,50 +5,70 @@ namespace app\common\constant; /** * 认证相关常量。 * - * 统一管理登录域、令牌状态、签名类型等枚举值。 + * 统一管理登录域、会话状态、接口凭证状态和开放接口签名类型。 */ final class AuthConstant { /** * 管理员登录域。 */ - public const GUARD_ADMIN = 1; + public const GUARD_ADMIN = 'admin'; /** * 商户登录域。 */ - public const GUARD_MERCHANT = 2; + public const GUARD_MERCHANT = 'merchant'; /** * JWT 签名算法。 */ - public const JWT_ALG_HS256 = 'HS256'; + public const JWT_ALGORITHM_HS256 = 'HS256'; /** - * 令牌禁用状态。 + * 会话令牌禁用状态。 */ public const TOKEN_STATUS_DISABLED = 0; /** - * 令牌启用状态。 + * 会话令牌启用状态。 */ public const TOKEN_STATUS_ENABLED = 1; /** - * 登录禁用状态。 + * 接口凭证禁用状态。 */ - public const LOGIN_STATUS_DISABLED = 0; + public const CREDENTIAL_STATUS_DISABLED = 0; /** - * 登录启用状态。 + * 接口凭证启用状态。 */ - public const LOGIN_STATUS_ENABLED = 1; + 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。 + */ + public const API_SIGN_NAME_SHA256_WITH_RSA = 'SHA256WithRSA'; + + /** + * API 签名类型归一化名称:SHA256WITHRSA。 + */ + public const API_SIGN_NORMALIZED_SHA256_WITH_RSA = 'SHA256WITHRSA'; + /** * 获取签名类型映射。 * @@ -57,24 +77,36 @@ final class AuthConstant public static function signTypeMap(): array { return [ - self::API_SIGN_TYPE_MD5 => 'MD5', + self::API_SIGN_TYPE_MD5 => self::API_SIGN_NAME_MD5, + self::API_SIGN_TYPE_SHA256_WITH_RSA => self::API_SIGN_NAME_SHA256_WITH_RSA, + ]; + } + + /** + * 获取接口凭证状态映射。 + * + * @return array 接口凭证状态名称表 + */ + public static function credentialStatusMap(): array + { + return [ + self::CREDENTIAL_STATUS_ENABLED => '启用', + self::CREDENTIAL_STATUS_DISABLED => '禁用', ]; } /** * 获取登录域映射。 * - * @return array 登录域名称表 + * @return array 登录域名称表 */ public static function guardMap(): array { return [ - self::GUARD_ADMIN => 'admin', - self::GUARD_MERCHANT => 'merchant', + self::GUARD_ADMIN => '管理后台', + self::GUARD_MERCHANT => '商户后台', ]; } } - - diff --git a/app/common/constant/CommonConstant.php b/app/common/constant/CommonConstant.php index ff82946..01e6d0c 100644 --- a/app/common/constant/CommonConstant.php +++ b/app/common/constant/CommonConstant.php @@ -18,12 +18,12 @@ final class CommonConstant public const STATUS_ENABLED = 1; /** - * 否。 + * 否,通常用于布尔类字段的数值表示。 */ public const NO = 0; /** - * 是。 + * 是,通常用于布尔类字段的数值表示。 */ public const YES = 1; @@ -56,4 +56,3 @@ final class CommonConstant - diff --git a/app/common/constant/EventConstant.php b/app/common/constant/EventConstant.php new file mode 100644 index 0000000..3f67290 --- /dev/null +++ b/app/common/constant/EventConstant.php @@ -0,0 +1,67 @@ + '清算入账', self::BIZ_TYPE_REFUND_REVERSE => '退款冲正', self::BIZ_TYPE_MANUAL_ADJUST => '人工调整', + self::BIZ_TYPE_TRANSFER_DEDUCT => '转账扣款', + self::BIZ_TYPE_TRANSFER_FEE => '转账手续费', + self::BIZ_TYPE_TRANSFER_RELEASE => '转账释放', ]; } @@ -69,5 +120,3 @@ final class LedgerConstant } - - diff --git a/app/common/constant/MerchantConstant.php b/app/common/constant/MerchantConstant.php index a28d005..8885266 100644 --- a/app/common/constant/MerchantConstant.php +++ b/app/common/constant/MerchantConstant.php @@ -68,4 +68,3 @@ final class MerchantConstant - diff --git a/app/common/constant/NotifyConstant.php b/app/common/constant/NotifyConstant.php index 02296b6..fb797a3 100644 --- a/app/common/constant/NotifyConstant.php +++ b/app/common/constant/NotifyConstant.php @@ -7,22 +7,74 @@ namespace app\common\constant; */ final class NotifyConstant { + /** + * 商户通知事件:支付成功。 + */ + public const EVENT_PAY_SUCCESS = 'PAY_SUCCESS'; + /** + * 商户通知事件:退款成功。 + */ + public const EVENT_REFUND_SUCCESS = 'REFUND_SUCCESS'; + /** + * 商户通知事件:清算完成。 + */ + public const EVENT_SETTLEMENT_SUCCESS = 'SETTLEMENT_SUCCESS'; + + /** + * 异步通知类型。 + */ public const NOTIFY_TYPE_ASYNC = 0; + /** + * 查单通知类型。 + */ public const NOTIFY_TYPE_QUERY = 1; + /** + * 异步回调类型。 + */ public const CALLBACK_TYPE_ASYNC = 0; + /** + * 同步回调类型。 + */ public const CALLBACK_TYPE_SYNC = 1; + /** + * 验证状态:未知。 + */ public const VERIFY_STATUS_UNKNOWN = 0; + /** + * 验证状态:成功。 + */ public const VERIFY_STATUS_SUCCESS = 1; + /** + * 验证状态:失败。 + */ public const VERIFY_STATUS_FAILED = 2; + /** + * 处理状态:待处理。 + */ public const PROCESS_STATUS_PENDING = 0; + /** + * 处理状态:成功。 + */ public const PROCESS_STATUS_SUCCESS = 1; + /** + * 处理状态:失败。 + */ public const PROCESS_STATUS_FAILED = 2; + /** + * 任务状态:待通知。 + */ public const TASK_STATUS_PENDING = 0; + /** + * 任务状态:成功。 + */ public const TASK_STATUS_SUCCESS = 1; + /** + * 任务状态:失败。 + */ public const TASK_STATUS_FAILED = 2; /** @@ -92,8 +144,20 @@ final class NotifyConstant self::TASK_STATUS_FAILED => '失败', ]; } + + /** + * 获取商户通知事件映射。 + * + * @return array 商户通知事件名称表 + */ + public static function eventTypeMap(): array + { + return [ + self::EVENT_PAY_SUCCESS => '支付成功', + self::EVENT_REFUND_SUCCESS => '退款成功', + self::EVENT_SETTLEMENT_SUCCESS => '清算完成', + ]; + } } - - diff --git a/app/common/constant/PaymentPluginStatusConstant.php b/app/common/constant/PaymentPluginStatusConstant.php new file mode 100644 index 0000000..629c6cf --- /dev/null +++ b/app/common/constant/PaymentPluginStatusConstant.php @@ -0,0 +1,98 @@ + + */ + public static function notifyStatuses(): array + { + return [ + self::SUCCESS, + self::FAILED, + self::PENDING, + ]; + } + + /** + * 插件查单成功状态别名。 + * + * @return array + */ + public static function successQueryAliases(): array + { + return [ + self::SUCCESS, + 'paid', + 'pay_success', + 'trade_success', + 'trade_finished', + 'finished', + 'successful', + ]; + } + + /** + * 插件查单失败状态别名。 + * + * @return array + */ + public static function failedQueryAliases(): array + { + return [ + self::FAILED, + 'fail', + 'error', + 'pay_error', + 'trade_fail', + ]; + } + + /** + * 插件查单关闭状态别名。 + * + * @return array + */ + public static function closedQueryAliases(): array + { + return [ + self::CLOSED, + 'close', + 'trade_closed', + ]; + } +} diff --git a/app/common/constant/RouteConstant.php b/app/common/constant/RouteConstant.php index d2065fb..753bd22 100644 --- a/app/common/constant/RouteConstant.php +++ b/app/common/constant/RouteConstant.php @@ -20,12 +20,12 @@ final class RouteConstant public const CHANNEL_TYPE_MERCHANT_SELF = 1; /** - * 代收通道模式。 + * 代收通道模式,资金直接进入平台侧。 */ public const CHANNEL_MODE_COLLECT = 0; /** - * 自收通道模式。 + * 自收通道模式,资金直接进入商户侧。 */ public const CHANNEL_MODE_SELF = 1; @@ -87,4 +87,3 @@ final class RouteConstant - diff --git a/app/common/constant/TradeConstant.php b/app/common/constant/TradeConstant.php index 9a334c5..7b4f8e3 100644 --- a/app/common/constant/TradeConstant.php +++ b/app/common/constant/TradeConstant.php @@ -7,33 +7,105 @@ namespace app\common\constant; */ final class TradeConstant { + /** + * D0 清算周期。 + */ public const SETTLEMENT_CYCLE_D0 = 0; + /** + * D1 清算周期。 + */ public const SETTLEMENT_CYCLE_D1 = 1; + /** + * D7 清算周期。 + */ public const SETTLEMENT_CYCLE_D7 = 2; + /** + * T1 清算周期。 + */ public const SETTLEMENT_CYCLE_T1 = 3; + /** + * 其他清算周期。 + */ public const SETTLEMENT_CYCLE_OTHER = 4; + /** + * 订单已创建,等待发起支付。 + */ public const ORDER_STATUS_CREATED = 0; + /** + * 订单支付中。 + */ public const ORDER_STATUS_PAYING = 1; + /** + * 订单支付成功。 + */ public const ORDER_STATUS_SUCCESS = 2; + /** + * 订单支付失败。 + */ public const ORDER_STATUS_FAILED = 3; + /** + * 订单已关闭。 + */ public const ORDER_STATUS_CLOSED = 4; + /** + * 订单已超时。 + */ public const ORDER_STATUS_TIMEOUT = 5; + /** + * 手续费未处理。 + */ public const FEE_STATUS_NONE = 0; + /** + * 手续费已冻结。 + */ public const FEE_STATUS_FROZEN = 1; + /** + * 手续费已扣除。 + */ public const FEE_STATUS_DEDUCTED = 2; + /** + * 手续费已释放。 + */ public const FEE_STATUS_RELEASED = 3; + /** + * 清算状态为空。 + */ public const SETTLEMENT_STATUS_NONE = 0; + /** + * 清算待处理。 + */ public const SETTLEMENT_STATUS_PENDING = 1; + /** + * 清算已完成。 + */ public const SETTLEMENT_STATUS_SETTLED = 2; + /** + * 清算已冲正。 + */ public const SETTLEMENT_STATUS_REVERSED = 3; + /** + * 退款单已创建。 + */ public const REFUND_STATUS_CREATED = 0; + /** + * 退款单处理中。 + */ public const REFUND_STATUS_PROCESSING = 1; + /** + * 退款单成功。 + */ public const REFUND_STATUS_SUCCESS = 2; + /** + * 退款单失败。 + */ public const REFUND_STATUS_FAILED = 3; + /** + * 退款单已关闭。 + */ public const REFUND_STATUS_CLOSED = 4; /** @@ -231,4 +303,3 @@ final class TradeConstant - diff --git a/app/common/constant/TransferConstant.php b/app/common/constant/TransferConstant.php new file mode 100644 index 0000000..a70f812 --- /dev/null +++ b/app/common/constant/TransferConstant.php @@ -0,0 +1,36 @@ + + */ + public static function transferStatusMap(): array + { + return [ + self::TRANSFER_STATUS_PENDING => '待处理', + self::TRANSFER_STATUS_SUCCESS => '成功', + self::TRANSFER_STATUS_FAILED => '失败', + ]; + } +} diff --git a/app/common/interface/PaymentInterface.php b/app/common/interface/PaymentInterface.php index 0b424c3..0b97402 100644 --- a/app/common/interface/PaymentInterface.php +++ b/app/common/interface/PaymentInterface.php @@ -23,6 +23,20 @@ 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`:渠道订单号,必须返回 + * - `chan_trade_no`:渠道交易号,可选;未生成时返回空字符串 + * - `ext_json`:插件私有轻量信息,可选;原始响应不要塞入支付单扩展 + * + * `pay_params` 必须带上对应 `type` 的必要载荷: + * - 跳转类:`redirect_url` / `payurl` / `mweb_url` + * - 二维码类:`qrcode_text` / `qrcode_data` / `qrcode_url` + * - 表单类:`html` 或 `action` + * - JSAPI / URL Scheme / 小程序:对应拉起参数或跳转参数 + * * @param array $order 订单参数 * @return array 下单结果 */ @@ -31,6 +45,15 @@ interface PaymentInterface /** * 查询订单状态。 * + * 建议返回当前系统标准结构,定时维护进程会按 `status` 推进支付单: + * - `success=true|false`:查询请求是否成功;查询失败不等于支付失败 + * - `status`:`success` / `failed` / `closed` / `pending` + * - `channel_order_no` / `channel_trade_no`:渠道单号 + * - `channel_status`:渠道原始状态,可选 + * - `message`:查询说明,可选 + * - `paid_at` / `failed_at`:终态时间,可选 + * - `ext_json`:插件私有轻量补充信息,可选 + * * @param array $order 订单参数 * @return array 查询结果 */ @@ -57,8 +80,24 @@ interface PaymentInterface /** * 解析并验证支付回调通知。 * + * 插件应返回当前系统统一可消费的结果结构,核心字段如下: + * - `status`:支付状态,限定为 `success` / `failed` / `pending` + * - `channel_order_no` / `channel_trade_no`:渠道单号,必须返回 + * - `channel_status`:渠道原始状态码或状态文本,可选 + * - `message`:回调处理说明,可选 + * - `channel_error_code` / `channel_error_msg`:渠道失败原因,可选 + * - `paid_at` / `failed_at`:支付成功或失败时间,可选 + * - `fee_actual_amount`:实际手续费,单位分,可选 + * - `ext_json`:插件私有的轻量补充信息,可选;原始回调和解析结果会进入回调日志,不要塞进支付单扩展 + * + * 插件在验签失败、报文非法或关键字段缺失时,应直接抛出 `PaymentException`。 + * 只有在回调可信时,才返回标准结果数组。 + * 如果第三方渠道只返回了一个唯一订单号,插件应同时填充 `channel_order_no` 和 `channel_trade_no`, + * 两个字段可以写成相同值。 + * 业务上尚未终态时返回 `status=pending`,由系统统一记录回调日志而不推进支付单终态。 + * * @param Request $request 请求对象 - * @return array 回调结果 + * @return array 回调结果 */ public function notify(Request $request): array; diff --git a/app/common/payment/AlipayPayment.php b/app/common/payment/AlipayPayment.php index 9612d81..600e325 100644 --- a/app/common/payment/AlipayPayment.php +++ b/app/common/payment/AlipayPayment.php @@ -6,6 +6,7 @@ namespace app\common\payment; use app\common\base\BasePayment; use app\common\constant\FileConstant; +use app\common\constant\PaymentPluginStatusConstant; use app\common\interface\PaymentInterface; use app\common\interface\PayPluginInterface; use app\common\util\FormatHelper; @@ -324,6 +325,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn $extra = isset($order['extra']) && is_array($order['extra']) ? $order['extra'] : []; if ($extra !== []) { $context = array_merge($context, $extra); + foreach (['merchant', 'payment', 'source'] as $section) { + if (isset($extra[$section]) && is_array($extra[$section])) { + $context = array_merge($context, $extra[$section]); + } + } } $param = $this->normalizeParamBag($context['param'] ?? null); @@ -459,7 +465,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn } catch (PaymentException $e) { throw $e; } catch (\Throwable $e) { - throw new PaymentException('支付宝下单失败:' . $e->getMessage(), 402, ['order_id' => $orderId]); + throw new PaymentException('支付宝下单失败', 402, ['order_id' => $orderId, 'error' => $e->getMessage()]); } } @@ -477,7 +483,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn 'pay_product' => self::PRODUCT_WEB, 'pay_action' => $this->productAction(self::PRODUCT_WEB), 'pay_params' => [ - 'type' => 'form', + 'type' => 'html', 'method' => 'POST', 'action' => '', 'html' => $body, @@ -507,7 +513,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn 'pay_product' => self::PRODUCT_H5, 'pay_action' => $this->productAction(self::PRODUCT_H5), 'pay_params' => [ - 'type' => 'form', + 'type' => 'html', 'method' => 'POST', 'action' => '', 'html' => $body, @@ -733,8 +739,8 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn $tradeNo = (string) $this->extractCollectionValue($result, ['trade_no', 'order_id', 'out_biz_no'], ''); $totalAmount = (string) $this->extractCollectionValue($result, ['total_amount', 'trans_amount', 'amount'], '0'); $status = match ($action) { - 'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? 'success' : $tradeStatus, - default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? 'success' : $tradeStatus, + 'transfer' => in_array($tradeStatus, ['SUCCESS', 'PAY_SUCCESS', 'SUCCESSFUL'], true) ? PaymentPluginStatusConstant::SUCCESS : $tradeStatus, + default => in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true) ? PaymentPluginStatusConstant::SUCCESS : $tradeStatus, }; return [ @@ -745,7 +751,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn 'pay_amount' => (int) round(((float) $totalAmount) * 100), ]; } catch (\Throwable $e) { - throw new PaymentException('支付宝查询失败:' . $e->getMessage(), 402); + throw new PaymentException('支付宝查询失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]); } } @@ -774,7 +780,7 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn Pay::alipay()->close($closeParams); return ['success' => true, 'msg' => '关闭成功', 'pay_product' => $product, 'pay_action' => $action]; } catch (\Throwable $e) { - throw new PaymentException('支付宝关单失败:' . $e->getMessage(), 402); + throw new PaymentException('支付宝关单失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]); } } @@ -823,11 +829,11 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn 'msg' => '退款成功', ]; } - throw new PaymentException($subMsg ?: '退款失败', 402); + throw new PaymentException('退款失败', 402, ['order_id' => $outTradeNo, 'sub_msg' => $subMsg]); } catch (PaymentException $e) { throw $e; } catch (\Throwable $e) { - throw new PaymentException('支付宝退款失败:' . $e->getMessage(), 402); + throw new PaymentException('支付宝退款失败', 402, ['order_id' => $outTradeNo, 'error' => $e->getMessage()]); } } @@ -852,22 +858,26 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn $paidAt = (string) $result->get('gmt_payment', ''); if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) { - throw new PaymentException('回调状态异常:' . $tradeStatus, 402); + throw new PaymentException('回调状态异常', 402, ['trade_status' => $tradeStatus]); } return [ - 'success' => true, - 'status' => 'success', - 'pay_order_id' => $outTradeNo, - 'chan_order_no' => $outTradeNo, - 'chan_trade_no' => $tradeNo, - 'amount' => (int) round(((float) $totalAmount) * 100), - 'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null, + 'status' => PaymentPluginStatusConstant::SUCCESS, + 'message' => '支付成功', + 'channel_order_no' => $outTradeNo, + 'channel_trade_no' => $tradeNo, + 'channel_status' => $tradeStatus, + 'paid_at' => $paidAt !== '' ? (FormatHelper::timestamp((int) strtotime($paidAt)) ?: null) : null, + 'fee_actual_amount' => null, + 'ext_json' => [ + 'channel_pay_amount' => (int) round(((float) $totalAmount) * 100), + 'channel_response' => $result->all(), + ], ]; } catch (PaymentException $e) { throw $e; } catch (\Throwable $e) { - throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402); + throw new PaymentException('支付宝回调验签失败', 402, ['error' => $e->getMessage()]); } } @@ -891,6 +901,3 @@ class AlipayPayment extends BasePayment implements PaymentInterface, PayPluginIn return 'fail'; } } - - - diff --git a/app/common/payment/EpayV1Payment.php b/app/common/payment/EpayV1Payment.php new file mode 100644 index 0000000..fd038f7 --- /dev/null +++ b/app/common/payment/EpayV1Payment.php @@ -0,0 +1,798 @@ + + */ + protected array $paymentInfo = [ + 'code' => 'epay_v1', + 'name' => 'ePay V1 网关', + 'author' => 'MPAY', + 'version' => '1.0.0', + 'pay_types' => ['alipay', 'wxpay'], + 'transfer_types' => [], + 'config_schema' => [ + [ + 'type' => 'input', + 'field' => 'gateway_url', + 'title' => '上游网关地址', + 'value' => '', + 'props' => [ + 'placeholder' => '例如:https://pay.example.com', + ], + 'validate' => [ + ['required' => true, 'message' => '上游网关地址不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'upstream_pid', + 'title' => '上游商户ID', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入第三方平台分配的 pid', + ], + 'validate' => [ + ['required' => true, 'message' => '上游商户ID不能为空'], + ], + ], + [ + 'type' => 'textarea', + 'field' => 'upstream_key', + 'title' => '上游 MD5 密钥', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入第三方平台分配的 API Key / KEY', + 'rows' => 4, + ], + 'validate' => [ + ['required' => true, 'message' => '上游 MD5 密钥不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'pay_path', + 'title' => '下单路径', + 'value' => '/mapi.php', + '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, + ], + ], + ], + ]; + + public function init(array $channelConfig): void + { + parent::init($channelConfig); + } + + 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), + ]; + + $param = $this->resolveParamValue($order); + 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 ((int) ($response['code'] ?? 0) !== 1) { + throw new PaymentException((string) ($response['msg'] ?? '上游 V1 下单失败'), 40200, [ + 'response' => $response, + ]); + } + + $channelNos = $this->resolveChannelNos($response + [ + 'trade_no' => (string) ($response['trade_no'] ?? $payload['out_trade_no']), + ]); + $payParams = $this->normalizePayResponse($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'], + ]; + } + + public function query(array $order): array + { + $payload = [ + 'act' => 'order', + 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), + 'key' => $this->requireConfigValue('upstream_key', '上游 MD5 密钥'), + ]; + + $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); + if ($tradeNo !== '') { + $payload['trade_no'] = $tradeNo; + } else { + $payload['out_trade_no'] = $this->resolveOrderNo($order); + } + + $response = $this->isMockEnabled() + ? $this->buildMockQueryResponse($order) + : $this->requestQueryJson($this->resolveGatewayUrl('api_path', '/api.php'), $payload); + if ((int) ($response['code'] ?? 0) !== 1) { + return [ + 'success' => false, + 'msg' => (string) ($response['msg'] ?? '上游 V1 查单失败'), + 'raw_data' => $response, + ]; + } + + $channelNos = $this->resolveChannelNos($response); + $status = (int) ($response['status'] ?? 0) === 1 + ? PaymentPluginStatusConstant::SUCCESS + : PaymentPluginStatusConstant::PENDING; + + return [ + 'success' => true, + 'status' => $status, + 'channel_order_no' => $channelNos['channel_order_no'], + 'channel_trade_no' => $channelNos['channel_trade_no'], + 'channel_status' => (string) ($response['status'] ?? ''), + 'paid_at' => $response['endtime'] ?? null, + 'ext_json' => [ + 'channel_response' => $response, + ], + ]; + } + + public function close(array $order): array + { + throw new PaymentException('上游 ePay V1 协议不支持关单', 40200, [ + 'plugin_code' => $this->getCode(), + 'order_no' => $this->resolveOrderNo($order), + ]); + } + + 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)), + ]; + + $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); + if ($tradeNo !== '') { + $payload['trade_no'] = $tradeNo; + } else { + $payload['out_trade_no'] = $this->resolveOrderNo($order); + } + + $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, + ]; + } + + return [ + 'success' => true, + 'msg' => (string) ($response['msg'] ?? 'success'), + 'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['trade_no'] ?? '')), + 'raw_data' => $response, + ]; + } + + public function notify(Request $request): array + { + $payload = $this->resolveNotifyPayload($request); + $this->verifyPayloadSignature( + $payload, + AuthConstant::API_SIGN_NAME_MD5, + $this->requireConfigValue('upstream_key', '上游 MD5 密钥'), + '上游 V1 回调验签失败' + ); + + $channelNos = $this->resolveChannelNos($payload); + $status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? '')); + + 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'] ?? ''), + ], + ]; + } + + public function notifySuccess(): string|Response + { + return 'success'; + } + + public function notifyFail(): string|Response + { + return 'fail'; + } + + /** + * 获取签名管理器。 + */ + private function signerManager(): EpaySignerManager + { + if ($this->epaySignerManager === null) { + /** @var EpaySignerManager $manager */ + $manager = container_make(EpaySignerManager::class, []); + $this->epaySignerManager = $manager; + } + + return $this->epaySignerManager; + } + + /** + * 是否启用插件内置 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); + } + + /** + * @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 + { + $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; + } + + /** + * 提取回调入参。 + * + * @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 new file mode 100644 index 0000000..6b51b39 --- /dev/null +++ b/app/common/payment/EpayV2Payment.php @@ -0,0 +1,961 @@ + + */ + protected array $paymentInfo = [ + 'code' => 'epay_v2', + 'name' => 'ePay V2 网关', + 'author' => 'MPAY', + 'version' => '1.0.0', + 'pay_types' => ['alipay', 'wxpay', 'unionpay'], + 'transfer_types' => [], + 'config_schema' => [ + [ + 'type' => 'input', + 'field' => 'gateway_url', + 'title' => '上游网关地址', + 'value' => '', + 'props' => [ + 'placeholder' => '例如:https://pay.example.com', + ], + 'validate' => [ + ['required' => true, 'message' => '上游网关地址不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'upstream_pid', + 'title' => '上游商户ID', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入第三方平台分配的 pid', + ], + 'validate' => [ + ['required' => true, 'message' => '上游商户ID不能为空'], + ], + ], + [ + 'type' => 'textarea', + 'field' => 'merchant_private_key', + 'title' => '上游商户私钥', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入对接上游 V2 的商户 RSA 私钥', + 'rows' => 6, + ], + 'validate' => [ + ['required' => true, 'message' => '上游商户私钥不能为空'], + ], + ], + [ + 'type' => 'textarea', + 'field' => 'platform_public_key', + 'title' => '上游平台公钥', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入上游平台 RSA 公钥', + 'rows' => 6, + ], + 'validate' => [ + ['required' => true, 'message' => '上游平台公钥不能为空'], + ], + ], + [ + 'type' => 'input', + 'field' => 'create_path', + 'title' => '下单路径', + 'value' => '/api/pay/create', + '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, + ], + ], + ], + ]; + + public function init(array $channelConfig): void + { + parent::init($channelConfig); + } + + 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)), + 'timestamp' => (string) time(), + 'clientip' => trim((string) ($order['client_ip'] ?? '127.0.0.1')), + ]; + + $returnUrl = trim((string) ($order['return_url'] ?? '')); + if ($returnUrl !== '') { + $payload['return_url'] = $returnUrl; + } + + $device = $this->resolveDevice($order); + if ($device !== '') { + $payload['device'] = $device; + } + + $param = $this->resolveParamValue($order); + if ($param !== '') { + $payload['param'] = $param; + } + + $context = $this->resolveExtraContext($order); + foreach (['auth_code', 'sub_openid', 'sub_appid'] as $key) { + $value = trim((string) ($context[$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 下单响应验签失败' + ); + + if ((int) ($response['code'] ?? -1) !== 0) { + throw new PaymentException((string) ($response['msg'] ?? '上游 V2 下单失败'), 40200, [ + 'response' => $response, + ]); + } + + $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); + + 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'], + ]; + } + + public function query(array $order): array + { + $payload = [ + 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), + 'timestamp' => (string) time(), + ]; + + $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); + if ($tradeNo !== '') { + $payload['trade_no'] = $tradeNo; + } else { + $payload['out_trade_no'] = $this->resolveOrderNo($order); + } + + $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 查单响应验签失败' + ); + + if ((int) ($response['code'] ?? -1) !== 0) { + return [ + 'success' => false, + 'msg' => (string) ($response['msg'] ?? '上游 V2 查单失败'), + 'raw_data' => $response, + ]; + } + + $channelNos = $this->resolveChannelNos($response); + $statusCode = (int) ($response['status'] ?? 0); + $status = match ($statusCode) { + 1, 2 => PaymentPluginStatusConstant::SUCCESS, + default => PaymentPluginStatusConstant::PENDING, + }; + + return [ + 'success' => true, + 'status' => $status, + 'channel_order_no' => $channelNos['channel_order_no'], + 'channel_trade_no' => $channelNos['channel_trade_no'], + 'channel_status' => (string) $statusCode, + 'paid_at' => $response['endtime'] ?? null, + 'ext_json' => [ + 'refundmoney' => (string) ($response['refundmoney'] ?? ''), + 'channel_response' => $response, + ], + ]; + } + + public function close(array $order): array + { + $payload = [ + 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), + 'timestamp' => (string) time(), + ]; + + $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); + if ($tradeNo !== '') { + $payload['trade_no'] = $tradeNo; + } else { + $payload['out_trade_no'] = $this->resolveOrderNo($order); + } + + $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 关单响应验签失败' + ); + + return [ + 'success' => (int) ($response['code'] ?? -1) === 0, + 'msg' => (string) ($response['msg'] ?? ''), + 'raw_data' => $response, + ]; + } + + public function refund(array $order): array + { + $payload = [ + 'pid' => $this->requireConfigValue('upstream_pid', '上游商户ID'), + 'money' => $this->amountToMoney((int) ($order['refund_amount'] ?? 0)), + 'timestamp' => (string) time(), + ]; + + $tradeNo = trim((string) ($order['chan_order_no'] ?? $order['chan_trade_no'] ?? '')); + if ($tradeNo !== '') { + $payload['trade_no'] = $tradeNo; + } else { + $payload['out_trade_no'] = $this->resolveOrderNo($order); + } + + $outRefundNo = trim((string) ($order['refund_no'] ?? '')); + if ($outRefundNo !== '') { + $payload['out_refund_no'] = $outRefundNo; + } + + $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 退款响应验签失败' + ); + + return [ + 'success' => (int) ($response['code'] ?? -1) === 0, + 'msg' => (string) ($response['msg'] ?? ''), + 'chan_refund_no' => trim((string) ($response['refund_no'] ?? $response['out_refund_no'] ?? '')), + 'raw_data' => $response, + ]; + } + + 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 回调验签失败' + ); + + $channelNos = $this->resolveChannelNos($payload); + $status = $this->normalizeNotifyStatus((string) ($payload['trade_status'] ?? '')); + + 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'] ?? ''), + ], + ]; + } + + public function notifySuccess(): string|Response + { + return 'success'; + } + + public function notifyFail(): string|Response + { + return 'fail'; + } + + /** + * 获取签名管理器。 + */ + private function signerManager(): EpaySignerManager + { + if ($this->epaySignerManager === null) { + /** @var EpaySignerManager $manager */ + $manager = container_make(EpaySignerManager::class, []); + $this->epaySignerManager = $manager; + } + + return $this->epaySignerManager; + } + + /** + * 是否启用插件内置 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 + { + $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; + } +} diff --git a/app/common/payment/TemplatePayment.php b/app/common/payment/TemplatePayment.php new file mode 100644 index 0000000..5a83fad --- /dev/null +++ b/app/common/payment/TemplatePayment.php @@ -0,0 +1,547 @@ + + */ + protected array $paymentInfo = [ + 'code' => 'template', + 'name' => '模板示例插件', + 'author' => 'MPAY', + 'version' => '1.0.0', + 'pay_types' => ['template'], + 'transfer_types' => [], + 'config_schema' => [ + [ + 'type' => 'input', + 'field' => 'gateway_url', + 'title' => '网关地址', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入第三方网关地址', + ], + 'validate' => [ + [ + 'required' => true, + 'message' => '网关地址不能为空', + ], + ], + ], + [ + 'type' => 'input', + 'field' => 'merchant_no', + 'title' => '商户号', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入商户号', + ], + 'validate' => [ + [ + 'required' => true, + 'message' => '商户号不能为空', + ], + ], + ], + [ + 'type' => 'input', + 'field' => 'app_id', + 'title' => '应用ID', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入应用ID', + ], + ], + [ + 'type' => 'textarea', + 'field' => 'app_secret', + 'title' => '签名密钥/私钥', + 'value' => '', + 'props' => [ + 'placeholder' => '请输入签名密钥或私钥内容', + 'rows' => 4, + ], + 'validate' => [ + [ + 'required' => true, + 'message' => '签名密钥不能为空', + ], + ], + ], + [ + 'type' => 'select', + 'field' => 'sign_type', + 'title' => '签名类型', + 'value' => AuthConstant::API_SIGN_NAME_MD5, + 'props' => [ + 'placeholder' => '请选择签名类型', + ], + 'options' => [ + [ + 'value' => AuthConstant::API_SIGN_NAME_MD5, + 'label' => AuthConstant::API_SIGN_NAME_MD5, + ], + [ + 'value' => 'RSA2', + 'label' => 'RSA2', + ], + ], + ], + [ + 'type' => 'select', + 'field' => 'default_product', + 'title' => '默认支付形态', + 'value' => 'html', + 'props' => [ + 'placeholder' => '请选择默认支付形态', + ], + 'options' => [ + [ + 'value' => 'html', + 'label' => '表单跳转', + ], + [ + 'value' => 'qrcode', + 'label' => '二维码', + ], + [ + 'value' => 'jump', + 'label' => '链接跳转', + ], + [ + 'value' => 'jsapi', + 'label' => 'JSAPI / 拉起参数', + ], + ], + ], + ], + ]; + + /** + * 初始化插件。 + * + * 模板插件这里只做基础注入;真实插件可以在这里初始化 SDK、缓存配置或预处理证书。 + * + * @param array $channelConfig 渠道配置 + * @return void + */ + public function init(array $channelConfig): void + { + parent::init($channelConfig); + } + + /** + * 发起支付下单。 + * + * 这里保留的是“模板返回结构”,便于复制后直接替换成真实第三方调用。 + * + * @param array $order 订单参数 + * @return array{ + * pay_product: string, + * pay_action: string, + * pay_params: array, + * chan_order_no: string, + * chan_trade_no: string + * } + * @throws PaymentException + */ + public function pay(array $order): array + { + $orderNo = $this->requireOrderNo($order); + $amount = $this->requireAmount($order); + $subject = $this->requireSubject($order); + $product = $this->resolveProduct($order); + $payload = $this->buildRequestPayload($order, $orderNo, $amount, $subject); + + return [ + 'pay_product' => $product, + 'pay_action' => $product, + 'pay_params' => $this->buildPayParams($product, $payload), + 'chan_order_no' => $orderNo, + 'chan_trade_no' => '', + ]; + } + + /** + * 查询订单状态。 + * + * 复制后请在这里替换成真实查单接口。 + * + * @param array $order 订单参数 + * @return array + * @throws PaymentException + */ + public function query(array $order): array + { + $this->throwTemplateTodo('查单'); + + return []; + } + + /** + * 关闭订单。 + * + * 复制后请在这里替换成真实关单接口。 + * + * @param array $order 订单参数 + * @return array + * @throws PaymentException + */ + public function close(array $order): array + { + $this->throwTemplateTodo('关单'); + + return []; + } + + /** + * 申请退款。 + * + * 复制后请在这里替换成真实退款接口。 + * + * @param array $order 订单参数 + * @return array + * @throws PaymentException + */ + public function refund(array $order): array + { + $this->throwTemplateTodo('退款'); + + return []; + } + + /** + * 解析并验证支付回调通知。 + * + * 复制后请在这里替换成真实回调验签和结果解析逻辑。 + * 验签失败直接抛出 `PaymentException`,验签通过后返回标准结果数组。 + * 如果渠道只返回一个唯一订单号,请同时填充 `channel_order_no` 和 `channel_trade_no`。 + * + * @param Request $request 请求对象 + * @return array + * @throws PaymentException + */ + public function notify(Request $request): array + { + $this->throwTemplateTodo('回调验签'); + + return []; + } + + /** + * 回调成功响应。 + * + * @return string|Response + */ + public function notifySuccess(): string|Response + { + return 'success'; + } + + /** + * 回调失败响应。 + * + * @return string|Response + */ + public function notifyFail(): string|Response + { + return 'fail'; + } + + /** + * 构造第三方请求参数。 + * + * 这里的字段只是示例,复制后按真实第三方接口自行增删。 + * + * @param array $order 原始订单参数 + * @param string $orderNo 商户订单号 + * @param int $amount 金额(分) + * @param string $subject 订单标题 + * @return array + */ + private function buildRequestPayload(array $order, string $orderNo, int $amount, string $subject): array + { + $payload = [ + 'merchant_no' => (string) $this->getConfig('merchant_no', ''), + 'app_id' => (string) $this->getConfig('app_id', ''), + 'pay_no' => (string) ($order['pay_no'] ?? $orderNo), + 'out_trade_no' => $orderNo, + 'biz_no' => (string) ($order['biz_no'] ?? ''), + 'trace_no' => (string) ($order['trace_no'] ?? ''), + 'channel_request_no' => (string) ($order['channel_request_no'] ?? ''), + 'amount' => $amount, + 'amount_yuan' => FormatHelper::amount($amount), + 'subject' => $subject, + 'body' => (string) ($order['body'] ?? ''), + 'notify_url' => (string) ($order['callback_url'] ?? ''), + 'return_url' => (string) ($order['return_url'] ?? ''), + 'device' => (string) ($order['_env'] ?? 'pc'), + 'extra' => $this->collectOrderContext($order), + ]; + + $signType = strtoupper((string) $this->getConfig('sign_type', AuthConstant::API_SIGN_NAME_MD5)); + $payload['sign_type'] = $signType !== '' ? $signType : AuthConstant::API_SIGN_NAME_MD5; + $payload['sign'] = 'TODO'; + + return $payload; + } + + /** + * 生成支付页返回参数。 + * + * @param string $product 支付形态 + * @param array $payload 请求参数 + * @return array + */ + private function buildPayParams(string $product, array $payload): array + { + $gatewayUrl = (string) $this->getConfig('gateway_url', ''); + + return match ($product) { + 'qrcode' => [ + 'type' => 'qrcode', + 'qrcode_text' => '请替换为真实二维码内容', + 'qrcode_url' => $gatewayUrl, + 'payload' => $payload, + ], + 'jump' => [ + 'type' => 'jump', + 'redirect_url' => $gatewayUrl, + 'payload' => $payload, + ], + 'jsapi' => [ + 'type' => 'jsapi', + 'order_string' => '请替换为真实调起参数', + 'payload' => $payload, + ], + default => [ + 'type' => 'html', + 'method' => 'POST', + 'action' => $gatewayUrl, + 'html' => $this->buildAutoSubmitForm($gatewayUrl, $payload), + 'payload' => $payload, + ], + }; + } + + /** + * 生成自动提交表单。 + * + * 这是很多表单跳转类插件最常见的返回方式,复制后可以直接改成真实字段。 + * + * @param string $action 表单地址 + * @param array $fields 表单字段 + * @return string HTML 片段 + */ + private function buildAutoSubmitForm(string $action, array $fields): string + { + if ($action === '') { + return ''; + } + + $inputs = ''; + foreach ($fields as $key => $value) { + if (is_array($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $value = $encoded !== false ? $encoded : ''; + } + + $key = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8'); + $value = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + $inputs .= sprintf('', $key, $value); + } + + $action = htmlspecialchars($action, ENT_QUOTES, 'UTF-8'); + + return sprintf( + '
%s
', + $action, + $inputs + ); + } + + /** + * 归一化订单上下文。 + * + * 支付单拉起时,`extra` 使用 merchant/payment/presentation/plugin 分区。 + * 模板把常用分区展开到同一层,方便新插件读取 `param`、`method`、`auth_code` 等字段。 + * + * @param array $order 原始订单参数 + * @return array + */ + private function collectOrderContext(array $order): array + { + $context = $order; + $extra = $this->normalizeBag($order['extra'] ?? null); + $context = array_merge($context, $extra); + foreach (['merchant', 'payment', 'source'] as $section) { + if (isset($extra[$section]) && is_array($extra[$section])) { + $context = array_merge($context, $extra[$section]); + } + } + $context = array_merge($context, $this->normalizeBag($order['param'] ?? null)); + + return $context; + } + + /** + * 标准化数组、JSON 字符串或查询字符串。 + * + * @param mixed $value 原始值 + * @return array + */ + private function normalizeBag(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value)) { + $value = trim($value); + if ($value === '') { + return []; + } + + $decoded = json_decode($value, true); + if (is_array($decoded)) { + return $decoded; + } + + parse_str($value, $parsed); + if (is_array($parsed) && $parsed !== []) { + return $parsed; + } + } + + return []; + } + + /** + * 解析默认支付形态。 + * + * @param array $order 原始订单参数 + * @return string + */ + private function resolveProduct(array $order): string + { + $context = $this->collectOrderContext($order); + $candidates = [ + $context['pay_product'] ?? null, + $context['product'] ?? null, + $context['pay_action'] ?? null, + $context['action'] ?? null, + ]; + + foreach ($candidates as $candidate) { + $product = $this->normalizeProductCode((string) $candidate); + if ($product !== '') { + return $product; + } + } + + return $this->normalizeProductCode((string) $this->getConfig('default_product', 'html')) ?: 'html'; + } + + /** + * 规范化支付形态标识。 + * + * @param string $product 原始标识 + * @return string 标准化后的标识 + */ + private function normalizeProductCode(string $product): string + { + $product = strtolower(trim($product)); + return in_array($product, ['html', 'qrcode', 'jump', 'jsapi'], true) ? $product : ''; + } + + /** + * 获取并校验订单号。 + * + * @param array $order 原始订单参数 + * @return string + * @throws PaymentException + */ + private function requireOrderNo(array $order): string + { + $orderNo = trim((string) ($order['order_id'] ?? $order['pay_no'] ?? $order['out_trade_no'] ?? '')); + if ($orderNo === '') { + throw new PaymentException('模板插件下单缺少订单号', 40200); + } + + return $orderNo; + } + + /** + * 获取并校验金额。 + * + * @param array $order 原始订单参数 + * @return int + * @throws PaymentException + */ + private function requireAmount(array $order): int + { + $amount = (int) ($order['amount'] ?? $order['pay_amount'] ?? $order['total_amount'] ?? 0); + if ($amount <= 0) { + throw new PaymentException('模板插件下单金额不合法', 40200); + } + + return $amount; + } + + /** + * 获取并校验订单标题。 + * + * @param array $order 原始订单参数 + * @return string + * @throws PaymentException + */ + private function requireSubject(array $order): string + { + $subject = trim((string) ($order['subject'] ?? $order['title'] ?? $order['body'] ?? '')); + if ($subject === '') { + throw new PaymentException('模板插件下单缺少标题', 40200); + } + + return $subject; + } + + /** + * 抛出模板占位异常。 + * + * @param string $action 当前动作 + * @return void + * @throws PaymentException + */ + private function throwTemplateTodo(string $action): void + { + throw new PaymentException(sprintf('模板插件示例未实现%s逻辑,请复制后接入真实网关', $action), 40200); + } +} diff --git a/app/common/util/JwtTokenManager.php b/app/common/util/JwtTokenManager.php index c15dfe0..cf2fcf3 100644 --- a/app/common/util/JwtTokenManager.php +++ b/app/common/util/JwtTokenManager.php @@ -6,6 +6,8 @@ use Firebase\JWT\ExpiredException; use Firebase\JWT\JWT; use Firebase\JWT\Key; use Firebase\JWT\SignatureInvalidException; +use app\common\constant\AuthConstant; +use app\exception\AuthConfigException; use support\Redis; use Throwable; @@ -29,6 +31,7 @@ class JwtTokenManager * @param array $sessionData 会话数据 * @param int|null $ttlSeconds 过期秒数 * @return array{token:string,expires_in:int,jti:string,claims:array,session:array} 签发结果 + * @throws AuthConfigException */ public function issue(string $guard, array $claims, array $sessionData, ?int $ttlSeconds = null): array { @@ -47,7 +50,7 @@ class JwtTokenManager 'guard' => $guard, ], $claims); - $token = JWT::encode($payload, (string) $guardConfig['secret'], 'HS256'); + $token = JWT::encode($payload, (string) $guardConfig['secret'], AuthConstant::JWT_ALGORITHM_HS256); $session = array_merge($sessionData, [ 'guard' => $guard, @@ -80,6 +83,7 @@ class JwtTokenManager * @param string $ip 最近访问 IP * @param string $userAgent 用户Agent * @return array{claims:array,session:array}|null 验证结果 + * @throws AuthConfigException */ public function verify(string $guard, string $token, string $ip = '', string $userAgent = ''): ?array { @@ -126,6 +130,7 @@ class JwtTokenManager * @param string $guard 登录域 * @param string $token JWT 字符串 * @return bool 是否已撤销 + * @throws AuthConfigException */ public function revoke(string $guard, string $token): bool { @@ -150,6 +155,7 @@ class JwtTokenManager * @param string $guard 登录域 * @param string $jti 会话标识 * @return bool 是否已撤销 + * @throws AuthConfigException */ public function revokeByJti(string $guard, string $jti): bool { @@ -168,6 +174,7 @@ class JwtTokenManager * @param string $guard 登录域 * @param string $jti 会话标识 * @return array|null 会话数据 + * @throws AuthConfigException */ public function session(string $guard, string $jti): ?array { @@ -188,6 +195,7 @@ class JwtTokenManager * @param string $guard 登录域 * @param string $token JWT 字符串 * @return array|null JWT 载荷 + * @throws AuthConfigException */ protected function decode(string $guard, string $token): ?array { @@ -196,7 +204,7 @@ class JwtTokenManager try { JWT::$leeway = (int) config('auth.leeway', 30); - $payload = JWT::decode($token, new Key((string) $guardConfig['secret'], 'HS256')); + $payload = JWT::decode($token, new Key((string) $guardConfig['secret'], AuthConstant::JWT_ALGORITHM_HS256)); } catch (ExpiredException|SignatureInvalidException|Throwable) { return null; } @@ -221,6 +229,7 @@ class JwtTokenManager * @param array $session 会话数据 * @param int $ttlSeconds 过期秒数 * @return void + * @throws AuthConfigException */ protected function storeSession(string $guard, string $jti, array $session, int $ttlSeconds): void { @@ -239,6 +248,7 @@ class JwtTokenManager * @param string $guard 登录域 * @param string $jti 会话标识 * @return string Redis 会话键 + * @throws AuthConfigException */ protected function sessionKey(string $guard, string $jti): string { @@ -250,13 +260,15 @@ class JwtTokenManager * * @param string $guard 登录域 * @return array 认证配置 - * @throws \InvalidArgumentException + * @throws AuthConfigException */ protected function guardConfig(string $guard): array { $guards = (array) config('auth.guards', []); if (!isset($guards[$guard])) { - throw new \InvalidArgumentException("Unknown auth guard: {$guard}"); + throw new AuthConfigException("未知认证域:{$guard}", [ + 'guard' => $guard, + ]); } return $guards[$guard]; @@ -268,7 +280,7 @@ class JwtTokenManager * @param string $guard 登录域 * @param string $secret 密钥 * @return void - * @throws RuntimeException + * @throws AuthConfigException */ protected function assertHmacSecretLength(string $guard, string $secret): void { @@ -277,17 +289,19 @@ class JwtTokenManager } $envNames = match ($guard) { - 'admin' => 'AUTH_ADMIN_JWT_SECRET or AUTH_JWT_SECRET', - 'merchant' => 'AUTH_MERCHANT_JWT_SECRET or AUTH_JWT_SECRET', - default => 'the configured JWT secret', + AuthConstant::GUARD_ADMIN => 'AUTH_ADMIN_JWT_SECRET 或 AUTH_JWT_SECRET', + AuthConstant::GUARD_MERCHANT => 'AUTH_MERCHANT_JWT_SECRET 或 AUTH_JWT_SECRET', + default => '当前配置的 JWT 密钥', }; - throw new \RuntimeException(sprintf( - 'JWT secret for guard "%s" is too short for HS256. Please set %s to at least 32 ASCII characters.', + throw new AuthConfigException(sprintf( + '认证域 "%s" 的 JWT 密钥长度不足,HS256 至少需要 32 个 ASCII 字符,请将 %s 配置为至少 32 个字符。', $guard, $envNames - )); + ), [ + 'guard' => $guard, + 'env_names' => $envNames, + 'secret_length' => strlen($secret), + ]); } } - - diff --git a/app/common/util/RsaKeyPairGenerator.php b/app/common/util/RsaKeyPairGenerator.php new file mode 100644 index 0000000..d40bdb9 --- /dev/null +++ b/app/common/util/RsaKeyPairGenerator.php @@ -0,0 +1,101 @@ + max(1024, $bits), + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'config' => $configPath, + ]); + if ($resource === false) { + throw new RuntimeException('生成 RSA 密钥对失败:' . self::collectOpenSslErrors()); + } + + $privateKey = ''; + if (!openssl_pkey_export($resource, $privateKey, null, ['config' => $configPath]) || trim($privateKey) === '') { + throw new RuntimeException('导出 RSA 私钥失败:' . self::collectOpenSslErrors()); + } + + $details = openssl_pkey_get_details($resource); + $publicKey = trim((string) ($details['key'] ?? '')); + if ($publicKey === '') { + throw new RuntimeException('导出 RSA 公钥失败'); + } + + return [ + 'private_key' => trim($privateKey), + 'public_key' => $publicKey, + ]; + } + + /** + * 查找可用的 OpenSSL 配置文件。 + * + * @return string 配置文件路径 + */ + private static function resolveOpenSslConfigPath(): string + { + $candidates = []; + + $envConfig = trim((string) getenv('OPENSSL_CONF')); + if ($envConfig !== '') { + $candidates[] = $envConfig; + } + + $baseDir = dirname(PHP_BINARY); + $candidates[] = $baseDir . DIRECTORY_SEPARATOR . 'extras' . DIRECTORY_SEPARATOR . 'ssl' . DIRECTORY_SEPARATOR . 'openssl.cnf'; + $candidates[] = $baseDir . DIRECTORY_SEPARATOR . 'openssl.cnf'; + $candidates[] = dirname($baseDir) . DIRECTORY_SEPARATOR . 'Apache2.4.39' . DIRECTORY_SEPARATOR . 'conf' . DIRECTORY_SEPARATOR . 'openssl.cnf'; + $candidates[] = 'C:\\Program Files\\Git\\mingw64\\etc\\ssl\\openssl.cnf'; + $candidates[] = 'C:\\Program Files\\Git\\usr\\ssl\\openssl.cnf'; + + foreach ($candidates as $candidate) { + $candidate = trim((string) $candidate); + if ($candidate !== '' && is_file($candidate)) { + return $candidate; + } + } + + return ''; + } + + /** + * 收集当前 OpenSSL 错误栈。 + * + * @return string 错误信息 + */ + private static function collectOpenSslErrors(): string + { + $messages = []; + while ($message = openssl_error_string()) { + $messages[] = $message; + } + + return $messages ? implode(' | ', $messages) : 'unknown error'; + } +} diff --git a/app/exception/AuthConfigException.php b/app/exception/AuthConfigException.php new file mode 100644 index 0000000..68e187c --- /dev/null +++ b/app/exception/AuthConfigException.php @@ -0,0 +1,30 @@ +data($data); + } + } +} diff --git a/app/exception/CommandException.php b/app/exception/CommandException.php new file mode 100644 index 0000000..05509bb --- /dev/null +++ b/app/exception/CommandException.php @@ -0,0 +1,47 @@ + $data 附加数据 + * @return void + */ + public function __construct(string $message = '命令执行失败', int $bizCode = 50002, ?\Throwable $previous = null, array $data = []) + { + parent::__construct($message, $bizCode, $previous); + + if ($previous !== null) { + $data = array_merge([ + 'previous_exception' => $previous::class, + 'previous_message' => $previous->getMessage(), + ], $data); + } + + $this->data($data); + } + + /** + * 获取附加数据。 + * + * @return array + */ + public function getData(): array + { + $data = $this->data(); + return is_array($data) ? $data : []; + } +} diff --git a/app/exception/UnauthorizedException.php b/app/exception/UnauthorizedException.php new file mode 100644 index 0000000..af12fda --- /dev/null +++ b/app/exception/UnauthorizedException.php @@ -0,0 +1,30 @@ +data($data); + } + } +} diff --git a/app/http/admin/controller/merchant/MerchantController.php b/app/http/admin/controller/merchant/MerchantController.php index 93c6a9c..c4b4933 100644 --- a/app/http/admin/controller/merchant/MerchantController.php +++ b/app/http/admin/controller/merchant/MerchantController.php @@ -3,6 +3,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\MerchantService; use support\Request; @@ -136,8 +137,9 @@ class MerchantController extends BaseController public function issueCredential(Request $request, string $id): Response { $merchantId = (int) $id; + $data = $this->validated($request->all(), MerchantApiCredentialValidator::class, 'issueCredential'); - return $this->success($this->merchantService->issueCredential($merchantId)); + return $this->success($this->merchantService->issueCredential($merchantId, $data)); } /** diff --git a/app/http/admin/controller/ops/MerchantNotifyTaskController.php b/app/http/admin/controller/ops/MerchantNotifyTaskController.php new file mode 100644 index 0000000..956f232 --- /dev/null +++ b/app/http/admin/controller/ops/MerchantNotifyTaskController.php @@ -0,0 +1,85 @@ +validated($request->all(), MerchantNotifyTaskValidator::class, 'index'); + + return $this->page( + $this->merchantNotifyTaskService->paginate( + $data, + (int) ($data['page'] ?? 1), + (int) ($data['page_size'] ?? 10) + ) + ); + } + + /** + * 查询商户通知任务详情。 + * + * @param Request $request 请求对象 + * @param string $notifyNo 通知号 + * @return Response 响应对象 + */ + public function show(Request $request, string $notifyNo): Response + { + $data = $this->validated(['notify_no' => $notifyNo], MerchantNotifyTaskValidator::class, 'show'); + $task = $this->merchantNotifyTaskService->findByNotifyNo((string) $data['notify_no']); + + if (!$task) { + return $this->fail('商户通知任务不存在', 404); + } + + return $this->success($task); + } + + /** + * 手动重试商户通知任务。 + * + * @param Request $request 请求对象 + * @param string $notifyNo 通知号 + * @return Response 响应对象 + */ + public function retry(Request $request, string $notifyNo): Response + { + $data = $this->validated(['notify_no' => $notifyNo], MerchantNotifyTaskValidator::class, 'retry'); + $task = $this->merchantNotifyTaskService->retry((string) $data['notify_no']); + + if (!$task) { + return $this->fail('商户通知任务不存在', 404); + } + + return $this->success($task, '商户通知任务已执行重试'); + } +} diff --git a/app/http/admin/controller/system/AuthController.php b/app/http/admin/controller/system/AuthController.php index 7d27ac1..5e4ba3f 100644 --- a/app/http/admin/controller/system/AuthController.php +++ b/app/http/admin/controller/system/AuthController.php @@ -86,6 +86,24 @@ class AuthController extends BaseController (string) $this->requestAttribute($request, 'auth.admin_username', '') )); } + + /** + * 修改当前登录管理员密码。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function changePassword(Request $request): Response + { + $adminId = $this->currentAdminId($request); + if ($adminId <= 0) { + return $this->fail('未获取到当前管理员信息', 401); + } + + $data = $this->validated($request->all(), AuthValidator::class, 'changePassword'); + + return $this->success($this->adminUserService->changePassword($adminId, $data)); + } } diff --git a/app/http/admin/controller/trade/PayOrderController.php b/app/http/admin/controller/trade/PayOrderController.php index 84e8a56..1078a95 100644 --- a/app/http/admin/controller/trade/PayOrderController.php +++ b/app/http/admin/controller/trade/PayOrderController.php @@ -11,7 +11,7 @@ use support\Response; /** * 支付订单管理控制器。 * - * 当前先提供列表查询,后续可以继续扩展支付单详情、关闭、重试等管理操作。 + * 当前提供列表查询和详情查看,便于后台直接排查支付链路。 * * @property PayOrderService $payOrderService 支付订单服务 */ @@ -42,6 +42,24 @@ class PayOrderController extends BaseController return $this->success($this->payOrderService->paginate($data, $page, $pageSize)); } + + /** + * 查询支付订单详情。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return Response 响应对象 + */ + public function show(Request $request, string $payNo): Response + { + $this->validated( + array_merge($request->all(), ['pay_no' => $payNo]), + PayOrderValidator::class, + 'show' + ); + + return $this->success($this->payOrderService->detail($payNo)); + } } diff --git a/app/http/admin/middleware/AdminAuthMiddleware.php b/app/http/admin/middleware/AdminAuthMiddleware.php index 7a8af82..3d8967b 100644 --- a/app/http/admin/middleware/AdminAuthMiddleware.php +++ b/app/http/admin/middleware/AdminAuthMiddleware.php @@ -2,6 +2,7 @@ namespace app\http\admin\middleware; +use app\exception\UnauthorizedException; use app\service\system\access\AdminAuthService; use support\Context; use Webman\Http\Request; @@ -34,6 +35,7 @@ class AdminAuthMiddleware implements MiddlewareInterface * @param Request $request 请求对象 * @param callable $handler handler * @return Response 响应对象 + * @throws UnauthorizedException */ public function process(Request $request, callable $handler): Response { @@ -42,11 +44,7 @@ class AdminAuthMiddleware implements MiddlewareInterface if ($token === '') { if ((int) env('AUTH_MIDDLEWARE_STRICT', 1) === 1) { - return json([ - 'code' => 401, - 'msg' => 'admin unauthorized', - 'data' => null, - ]); + throw new UnauthorizedException('管理员未授权'); } } else { $admin = $this->adminAuthService->authenticateToken( @@ -55,11 +53,7 @@ class AdminAuthMiddleware implements MiddlewareInterface $request->header('user-agent', '') ); if (!$admin) { - return json([ - 'code' => 401, - 'msg' => 'admin unauthorized', - 'data' => null, - ]); + throw new UnauthorizedException('管理员未授权'); } Context::set('auth.admin_id', (int) $admin->id); diff --git a/app/http/admin/validation/AuthValidator.php b/app/http/admin/validation/AuthValidator.php index 31c6056..11a0d8e 100644 --- a/app/http/admin/validation/AuthValidator.php +++ b/app/http/admin/validation/AuthValidator.php @@ -19,6 +19,8 @@ class AuthValidator extends Validator protected array $rules = [ 'username' => 'required|string|min:1|max:32', 'password' => 'required|string|min:6|max:100', + 'current_password' => 'required|string|min:6|max:100', + 'password_confirm' => 'required|string|min:6|max:100|same:password', ]; /** @@ -29,6 +31,8 @@ class AuthValidator extends Validator protected array $attributes = [ 'username' => '用户名', 'password' => '密码', + 'current_password' => '旧密码', + 'password_confirm' => '确认密码', ]; /** @@ -38,6 +42,7 @@ class AuthValidator extends Validator */ protected array $scenes = [ 'login' => ['username', 'password'], + 'changePassword' => ['current_password', 'password', 'password_confirm'], ]; } diff --git a/app/http/admin/validation/MerchantApiCredentialValidator.php b/app/http/admin/validation/MerchantApiCredentialValidator.php index 7e69e95..c669561 100644 --- a/app/http/admin/validation/MerchantApiCredentialValidator.php +++ b/app/http/admin/validation/MerchantApiCredentialValidator.php @@ -18,8 +18,11 @@ 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', + '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', + 'merchant_public_key' => 'nullable|string|max:65535', 'status' => 'sometimes|integer|in:0,1', 'page' => 'sometimes|integer|min:1', 'page_size' => 'sometimes|integer|min:1|max:100', @@ -35,7 +38,10 @@ class MerchantApiCredentialValidator extends Validator 'keyword' => '关键词', 'merchant_id' => '所属商户', 'sign_type' => '签名类型', + 'rotate_v1' => '是否重置 V1', + 'rotate_v2' => '是否重置 V2', 'api_key' => '接口凭证值', + 'merchant_public_key' => '商户公钥', 'status' => '接口凭证状态', 'page' => '页码', 'page_size' => '每页条数', @@ -48,10 +54,11 @@ class MerchantApiCredentialValidator extends Validator */ protected array $scenes = [ 'index' => ['keyword', 'merchant_id', 'status', 'page', 'page_size'], - 'store' => ['merchant_id', 'sign_type', 'api_key', 'status'], - 'update' => ['id', 'sign_type', 'api_key', 'status'], + 'store' => ['merchant_id', 'sign_type', 'api_key', 'merchant_public_key', 'status'], + 'update' => ['id', 'sign_type', 'api_key', 'merchant_public_key', 'status'], 'show' => ['id'], 'destroy' => ['id'], + 'issueCredential' => ['rotate_v1', 'rotate_v2', 'sign_type', 'status'], ]; /** @@ -63,7 +70,8 @@ class MerchantApiCredentialValidator extends Validator { return $this->appendRules([ 'merchant_id' => 'required|integer|min:1|exists:ma_merchant,id', - 'sign_type' => 'required|integer|in:0', + 'sign_type' => 'required|integer|in:0,1', + 'merchant_public_key' => 'nullable|string|max:65535', 'status' => 'required|integer|in:0,1', ]); } @@ -77,8 +85,25 @@ class MerchantApiCredentialValidator extends Validator { return $this->appendRules([ 'id' => 'required|integer|min:1', - 'sign_type' => 'required|integer|in:0', - 'status' => 'required|integer|in:0,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', + ]); + } + + /** + * 配置生成接口凭证场景规则。 + * + * @return static 校验器实例 + */ + public function sceneIssueCredential(): static + { + 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/MerchantNotifyTaskValidator.php b/app/http/admin/validation/MerchantNotifyTaskValidator.php new file mode 100644 index 0000000..3c96391 --- /dev/null +++ b/app/http/admin/validation/MerchantNotifyTaskValidator.php @@ -0,0 +1,72 @@ + 'sometimes|string|max:64', + 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:1', + 'status' => 'sometimes|integer|in:0,1,2', + 'page' => 'sometimes|integer|min:1', + 'page_size' => 'sometimes|integer|min:1|max:100', + ]; + + /** + * 字段别名。 + * + * @var array + */ + protected array $attributes = [ + 'notify_no' => '通知号', + 'keyword' => '关键词', + 'merchant_id' => '所属商户', + 'status' => '任务状态', + 'page' => '页码', + 'page_size' => '每页条数', + ]; + + /** + * 校验场景。 + * + * @var array + */ + protected array $scenes = [ + 'index' => ['keyword', 'merchant_id', 'status', 'page', 'page_size'], + 'show' => ['notify_no'], + 'retry' => ['notify_no'], + ]; + + /** + * 配置详情场景规则。 + * + * @return static + */ + public function sceneShow(): static + { + return $this->appendRules([ + 'notify_no' => 'required|string|max:64', + ]); + } + + /** + * 配置重试场景规则。 + * + * @return static + */ + public function sceneRetry(): static + { + return $this->sceneShow(); + } +} diff --git a/app/http/admin/validation/PayOrderValidator.php b/app/http/admin/validation/PayOrderValidator.php index 8178f03..999aa3d 100644 --- a/app/http/admin/validation/PayOrderValidator.php +++ b/app/http/admin/validation/PayOrderValidator.php @@ -17,6 +17,7 @@ class PayOrderValidator extends Validator * @var array */ protected array $rules = [ + 'pay_no' => 'sometimes|string|max:32', 'keyword' => 'sometimes|string|max:128', 'merchant_id' => 'sometimes|integer|min:1', 'pay_type_id' => 'sometimes|integer|min:1', @@ -33,6 +34,7 @@ class PayOrderValidator extends Validator * @var array */ protected array $attributes = [ + 'pay_no' => '支付单号', 'keyword' => '关键字', 'merchant_id' => '商户ID', 'pay_type_id' => '支付方式', @@ -50,6 +52,6 @@ class PayOrderValidator extends Validator */ protected array $scenes = [ 'index' => ['keyword', 'merchant_id', 'pay_type_id', 'status', 'channel_mode', 'callback_status', 'page', 'page_size'], + 'show' => ['pay_no'], ]; } - diff --git a/app/http/admin/validation/PaymentPluginConfValidator.php b/app/http/admin/validation/PaymentPluginConfValidator.php index 31a6a75..4df7893 100644 --- a/app/http/admin/validation/PaymentPluginConfValidator.php +++ b/app/http/admin/validation/PaymentPluginConfValidator.php @@ -19,6 +19,7 @@ class PaymentPluginConfValidator extends Validator protected array $rules = [ 'id' => 'sometimes|integer|min:1', 'keyword' => 'sometimes|string|max:128', + 'merchant_id' => 'sometimes|integer|min:0', 'plugin_code' => 'sometimes|string|alpha_dash|min:2|max:32', 'config' => 'nullable|array', 'settlement_cycle_type' => 'sometimes|integer|in:0,1,2,3,4', @@ -37,6 +38,7 @@ class PaymentPluginConfValidator extends Validator protected array $attributes = [ 'id' => '配置ID', 'keyword' => '关键字', + 'merchant_id' => '所属商户', 'plugin_code' => '插件编码', 'config' => '插件配置', 'settlement_cycle_type' => '结算周期', @@ -53,9 +55,9 @@ class PaymentPluginConfValidator extends Validator * @var array */ protected array $scenes = [ - 'index' => ['keyword', 'plugin_code', 'page', 'page_size'], - 'store' => ['plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], - 'update' => ['id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], + 'index' => ['keyword', 'merchant_id', 'plugin_code', 'page', 'page_size'], + 'store' => ['merchant_id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], + 'update' => ['id', 'merchant_id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], 'show' => ['id'], 'destroy' => ['id'], 'options' => ['plugin_code'], @@ -87,4 +89,3 @@ class PaymentPluginConfValidator extends Validator } } - diff --git a/app/http/admin/validation/PaymentPluginValidator.php b/app/http/admin/validation/PaymentPluginValidator.php index ab0a826..84b722b 100644 --- a/app/http/admin/validation/PaymentPluginValidator.php +++ b/app/http/admin/validation/PaymentPluginValidator.php @@ -19,6 +19,7 @@ class PaymentPluginValidator extends Validator protected array $rules = [ 'code' => 'sometimes|string|alpha_dash|min:2|max:32', 'status' => 'sometimes|integer|in:0,1', + 'allow_merchant' => 'sometimes|integer|in:0,1', 'remark' => 'nullable|string|max:500', 'keyword' => 'sometimes|string|max:128', 'name' => 'sometimes|string|max:50', @@ -37,6 +38,7 @@ class PaymentPluginValidator extends Validator 'code' => '插件编码', 'name' => '插件名称', 'status' => '插件状态', + 'allow_merchant' => '商户端可用', 'remark' => '备注', 'keyword' => '关键字', 'page' => '页码', @@ -52,10 +54,9 @@ class PaymentPluginValidator extends Validator */ protected array $scenes = [ 'index' => ['keyword', 'code', 'name', 'status', 'page', 'page_size'], - 'update' => ['code', 'status', 'remark'], + 'update' => ['code', 'status', 'allow_merchant', 'remark'], 'updateStatus' => ['code', 'status'], 'show' => ['code'], 'selectOptions' => ['keyword', 'page', 'page_size', 'pay_type_code', 'ids'], ]; } - diff --git a/app/http/api/controller/cashier/CashierController.php b/app/http/api/controller/cashier/CashierController.php new file mode 100644 index 0000000..3293653 --- /dev/null +++ b/app/http/api/controller/cashier/CashierController.php @@ -0,0 +1,67 @@ +validated($request->all(), CashierValidator::class, 'context'); + + return $this->success( + $this->cashierService->context((string) ($payload['biz_no'] ?? '')) + ); + } + + /** + * 确认收银台支付方式。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function confirm(Request $request): Response + { + $payload = $this->validated($request->all(), CashierValidator::class, 'confirm'); + + return $this->success( + $this->cashierService->confirm($payload, $request) + ); + } + + /** + * 查询支付页详情。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function payOrder(Request $request): Response + { + $payload = $this->validated($request->all(), CashierValidator::class, 'pay_order'); + + return $this->success( + $this->cashierService->payOrderDetail((string) ($payload['pay_no'] ?? '')) + ); + } +} diff --git a/app/http/api/controller/epay/EpayV1Controller.php b/app/http/api/controller/epay/EpayV1Controller.php new file mode 100644 index 0000000..844dacc --- /dev/null +++ b/app/http/api/controller/epay/EpayV1Controller.php @@ -0,0 +1,87 @@ +validated($request->all(), EpayV1Validator::class, 'submit'); + return $this->epayV1ProtocolService->submit($payload, $request); + } + + /** + * API 支付入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function mapi(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV1Validator::class, 'mapi'); + return json($this->epayV1ProtocolService->mapi($payload, $request)); + } + + /** + * 旧版兼容 API 入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function api(Request $request): Response + { + $payload = $request->all(); + $scene = $this->resolveApiScene((string) ($payload['act'] ?? '')); + if ($scene === null) { + return json(['code' => 0, 'msg' => '不支持的操作类型']); + } + + $payload = $this->validated($payload, EpayV1Validator::class, $scene); + return json($this->epayV1ProtocolService->api($payload)); + } + + /** + * 映射旧版 `act` 到验证场景。 + * + * @param string $act 接口动作 + * @return string|null 验证场景 + */ + private function resolveApiScene(string $act): ?string + { + return match (strtolower(trim($act))) { + 'query' => 'api_query', + 'settle' => 'api_settle', + 'order' => 'api_order', + 'orders' => 'api_orders', + 'refund' => 'api_refund', + default => null, + }; + } +} diff --git a/app/http/api/controller/epay/EpayV2Controller.php b/app/http/api/controller/epay/EpayV2Controller.php new file mode 100644 index 0000000..3ebef68 --- /dev/null +++ b/app/http/api/controller/epay/EpayV2Controller.php @@ -0,0 +1,173 @@ +validated($request->all(), EpayV2Validator::class, 'submit'); + return $this->epayV2ProtocolService->submit($payload, $request); + } + + /** + * API 下单入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function create(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'create'); + return json($this->epayV2ProtocolService->create($payload, $request)); + } + + /** + * 支付单查询入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function query(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'query'); + return json($this->epayV2ProtocolService->query($payload)); + } + + /** + * 退款发起入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function refund(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'refund'); + return json($this->epayV2ProtocolService->refund($payload)); + } + + /** + * 退款查询入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function refundQuery(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'refund_query'); + return json($this->epayV2ProtocolService->refundQuery($payload)); + } + + /** + * 关闭订单入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function close(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'close'); + return json($this->epayV2ProtocolService->close($payload)); + } + + /** + * 商户信息查询入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function merchantInfo(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'merchant_info'); + return json($this->epayV2ProtocolService->merchantInfo($payload)); + } + + /** + * 商户订单列表入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function merchantOrders(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'merchant_orders'); + return json($this->epayV2ProtocolService->merchantOrders($payload)); + } + + /** + * 转账发起入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function transferSubmit(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'transfer_submit'); + return json($this->epayV2ProtocolService->transferSubmit($payload)); + } + + /** + * 转账查询入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function transferQuery(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'transfer_query'); + return json($this->epayV2ProtocolService->transferQuery($payload)); + } + + /** + * 转账余额查询入口。 + * + * @param Request $request 请求对象 + * @return Response 响应对象 + */ + public function transferBalance(Request $request): Response + { + $payload = $this->validated($request->all(), EpayV2Validator::class, 'transfer_balance'); + return json($this->epayV2ProtocolService->transferBalance($payload)); + } + + /** + * 渠道回调入口。 + * + * @param Request $request 请求对象 + * @param string $payNo 支付单号 + * @return string|Response + */ + public function callback(Request $request, string $payNo): string|Response + { + return $this->payOrderService->handlePluginCallback($payNo, $request); + } +} diff --git a/app/http/api/validation/CashierValidator.php b/app/http/api/validation/CashierValidator.php new file mode 100644 index 0000000..11c4150 --- /dev/null +++ b/app/http/api/validation/CashierValidator.php @@ -0,0 +1,68 @@ + 'required|string|max:32', + 'pay_no' => 'required|string|max:32', + 'type' => 'nullable|string|max:32', + ]; + + protected array $attributes = [ + 'biz_no' => '业务单号', + 'pay_no' => '支付单号', + 'type' => '支付方式', + ]; + + protected array $scenes = [ + 'context' => ['biz_no'], + 'confirm' => ['biz_no', 'type'], + 'pay_order' => ['pay_no'], + ]; + + /** + * 收银台上下文场景。 + * + * @return static + */ + public function sceneContext(): static + { + return $this->appendRules([ + 'biz_no' => 'required|string|max:32', + ]); + } + + /** + * 收银台确认场景。 + * + * @return static + */ + public function sceneConfirm(): static + { + return $this->appendRules([ + 'biz_no' => 'required|string|max:32', + 'type' => 'required|string|max:32', + ]); + } + + /** + * 支付页详情场景。 + * + * @return static + */ + public function scenePayOrder(): 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 new file mode 100644 index 0000000..767df33 --- /dev/null +++ b/app/http/api/validation/EpayV1Validator.php @@ -0,0 +1,168 @@ + 'sometimes|string|in:query,settle,order,orders,refund', + 'pid' => 'required|integer|min:1', + 'key' => 'nullable|string|max:128', + 'type' => 'nullable|string|max:32', + 'trade_no' => 'nullable|string|max:64', + 'out_trade_no' => 'nullable|string|max:64', + 'notify_url' => 'nullable|string|max:255', + 'return_url' => 'nullable|string|max:255', + 'name' => 'nullable|string|max:255', + 'money' => 'nullable|regex:/^\d+(?:\.\d{1,2})?$/', + 'param' => 'nullable', + 'clientip' => 'nullable|ip', + 'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay,jump', + 'sign' => 'nullable|string|max:255', + 'sign_type' => 'sometimes|string|in:MD5', + 'page' => 'sometimes|integer|min:1', + 'limit' => 'sometimes|integer|min:1|max:50', + ]; + + protected array $attributes = [ + 'act' => '操作类型', + 'pid' => '商户ID', + 'key' => '商户密钥', + 'type' => '支付方式', + 'trade_no' => '平台订单号', + 'out_trade_no' => '商户订单号', + 'notify_url' => '异步通知地址', + 'return_url' => '跳转通知地址', + 'name' => '商品名称', + 'money' => '商品金额', + 'param' => '业务扩展参数', + 'clientip' => '用户 IP', + 'device' => '设备类型', + 'sign' => '签名字符串', + 'sign_type' => '签名类型', + 'page' => '页码', + 'limit' => '数量', + ]; + + protected array $scenes = [ + 'submit' => ['pid', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'sign', 'sign_type'], + 'mapi' => ['pid', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'clientip', 'device', 'sign', 'sign_type'], + 'api_query' => ['act', 'pid', 'key'], + 'api_settle' => ['act', 'pid', 'key'], + 'api_order' => ['act', 'pid', 'key', 'trade_no', 'out_trade_no'], + 'api_orders' => ['act', 'pid', 'key', 'page', 'limit'], + 'api_refund' => ['act', 'pid', 'key', 'trade_no', 'out_trade_no', 'money'], + ]; + + /** + * 页面跳转支付场景。 + * + * @return static + */ + public function sceneSubmit(): static + { + return $this->appendRules([ + 'out_trade_no' => 'required|string|max:64', + '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:MD5', + 'sign' => 'required|string|max:255', + ]); + } + + /** + * API 支付场景。 + * + * @return static + */ + public function sceneMapi(): static + { + return $this->appendRules([ + 'type' => 'required|string|max:32', + 'out_trade_no' => 'required|string|max:64', + 'notify_url' => 'required|string|max:255', + 'return_url' => 'nullable|string|max:255', + 'name' => 'required|string|max:255', + 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', + 'clientip' => 'required|ip', + 'sign_type' => 'required|string|in:MD5', + 'sign' => 'required|string|max:255', + ]); + } + + /** + * 商户信息查询场景。 + * + * @return static + */ + public function sceneApiQuery(): static + { + return $this->appendRules([ + 'key' => 'required|string|max:128', + ]); + } + + /** + * 结算记录查询场景。 + * + * @return static + */ + public function sceneApiSettle(): static + { + return $this->appendRules([ + 'key' => 'required|string|max:128', + ]); + } + + /** + * 单个订单查询场景。 + * + * @return static + */ + public function sceneApiOrder(): static + { + return $this->appendRules([ + 'key' => 'required|string|max:128', + 'trade_no' => 'nullable|string|max:64|required_without:out_trade_no', + 'out_trade_no' => 'nullable|string|max:64|required_without:trade_no', + ]); + } + + /** + * 订单列表查询场景。 + * + * @return static + */ + public function sceneApiOrders(): static + { + return $this->appendRules([ + 'key' => 'required|string|max:128', + 'page' => 'sometimes|integer|min:1', + 'limit' => 'sometimes|integer|min:1|max:50', + ]); + } + + /** + * 退款申请场景。 + * + * @return static + */ + public function sceneApiRefund(): static + { + return $this->appendRules([ + 'key' => 'required|string|max:128', + 'money' => 'required|regex:/^\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 new file mode 100644 index 0000000..f40691c --- /dev/null +++ b/app/http/api/validation/EpayV2Validator.php @@ -0,0 +1,216 @@ + 'required|integer|min:1', + 'timestamp' => 'required|integer|min:1', + // 兼容旧版 SDK 里使用的 `RSA` 简写,同时内部统一按 SHA256WithRSA 验签。 + 'sign_type' => 'required|string|in:SHA256WithRSA,RSA', + // RSA 签名是 Base64 文本,长度会明显超过 MD5,不能沿用 255 的短限制。 + 'sign' => 'required|string|max:2048', + 'type' => 'nullable|string|max:32', + 'method' => 'nullable|string|in:web,jump,jsapi,app,scan,applet', + 'trade_no' => 'nullable|string|max:64', + 'out_trade_no' => 'nullable|string|max:64', + 'notify_url' => 'nullable|string|max:255', + 'return_url' => 'nullable|string|max:255', + 'name' => 'nullable|string|max:255', + 'money' => 'nullable|regex:/^\d+(?:\.\d{1,2})?$/', + 'param' => 'nullable', + 'auth_code' => 'nullable|string|max:128', + 'sub_openid' => 'nullable|string|max:128', + 'sub_appid' => 'nullable|string|max:64', + 'clientip' => 'nullable|ip', + 'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay', + 'channel_id' => 'nullable|integer|min:0', + 'offset' => 'sometimes|integer|min:0', + 'limit' => 'sometimes|integer|min:1|max:50', + 'status' => 'nullable|integer|min:0|max:5', + 'refund_no' => 'nullable|string|max:64', + 'out_refund_no' => 'nullable|string|max:64', + 'biz_no' => 'nullable|string|max:32', + 'out_biz_no' => 'nullable|string|max:64', + 'account' => 'nullable|string|max:100', + 'bookid' => 'nullable|string|max:64', + 'remark' => 'nullable|string|max:255', + ]; + + protected array $attributes = [ + 'pid' => '商户ID', + 'timestamp' => '时间戳', + 'sign_type' => '签名类型', + 'sign' => '签名字符串', + 'type' => '支付方式', + 'method' => '接口类型', + 'trade_no' => '平台订单号', + 'out_trade_no' => '商户订单号', + 'notify_url' => '异步通知地址', + 'return_url' => '跳转通知地址', + 'name' => '商品名称', + 'money' => '商品金额', + 'param' => '业务扩展参数', + 'auth_code' => '授权码', + 'sub_openid' => '子用户 OPENID', + 'sub_appid' => '子应用 APPID', + 'clientip' => '用户 IP', + 'device' => '设备类型', + 'channel_id' => '渠道ID', + 'offset' => '偏移量', + 'limit' => '数量', + 'status' => '状态', + 'refund_no' => '退款单号', + 'out_refund_no' => '商户退款单号', + 'biz_no' => '平台业务号', + 'out_biz_no' => '商户转账单号', + 'account' => '收款账号', + 'bookid' => '书签ID', + 'remark' => '备注', + ]; + + protected array $scenes = [ + 'submit' => ['pid', 'timestamp', 'sign_type', 'sign', 'type', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'channel_id'], + 'create' => ['pid', 'timestamp', 'sign_type', 'sign', 'type', 'method', 'out_trade_no', 'notify_url', 'return_url', 'name', 'money', 'param', 'auth_code', 'sub_openid', 'sub_appid', 'clientip', 'device', 'channel_id'], + 'query' => ['pid', 'timestamp', 'sign_type', 'sign', 'trade_no', 'out_trade_no'], + 'refund' => ['pid', 'timestamp', 'sign_type', 'sign', 'trade_no', 'out_trade_no', 'money', 'out_refund_no'], + 'refund_query' => ['pid', 'timestamp', 'sign_type', 'sign', 'refund_no', 'out_refund_no'], + 'close' => ['pid', 'timestamp', 'sign_type', 'sign', 'trade_no', 'out_trade_no'], + 'merchant_info' => ['pid', 'timestamp', 'sign_type', 'sign'], + 'merchant_orders' => ['pid', 'timestamp', 'sign_type', 'sign', 'offset', 'limit', 'status'], + 'transfer_submit' => ['pid', 'timestamp', 'sign_type', 'sign', 'type', 'account', 'name', 'money', 'out_biz_no', 'remark', 'bookid'], + 'transfer_query' => ['pid', 'timestamp', 'sign_type', 'sign', 'biz_no', 'out_biz_no'], + 'transfer_balance' => ['pid', 'timestamp', 'sign_type', 'sign'], + ]; + + /** + * 页面跳转支付场景。 + * + * @return static + */ + public function sceneSubmit(): static + { + return $this->appendRules([ + 'type' => 'nullable|string|max:32', + 'out_trade_no' => 'required|string|max:64', + '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', + 'sign' => 'required|string|max:2048', + ]); + } + + /** + * API 下单场景。 + * + * @return static + */ + public function sceneCreate(): static + { + return $this->appendRules([ + 'type' => 'required|string|max:32', + 'method' => 'required|string|in:web,jump,jsapi,app,scan,applet', + 'out_trade_no' => 'required|string|max:64', + 'notify_url' => 'required|string|max:255', + 'return_url' => 'nullable|string|max:255', + 'name' => 'required|string|max:255', + 'money' => 'required|regex:/^\d+(?:\.\d{1,2})?$/', + 'device' => 'nullable|string|in:pc,mobile,qq,wechat,alipay', + 'sign_type' => 'required|string|in:SHA256WithRSA,RSA', + 'sign' => 'required|string|max:2048', + ]); + } + + /** + * 退款发起场景。 + * + * @return static + */ + public function sceneRefund(): static + { + return $this->appendRules([ + 'money' => 'required|regex:/^\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' => 'required|string|max:2048', + ]); + } + + /** + * 支付单查询场景。 + * + * @return static + */ + public function sceneQuery(): static + { + return $this->appendRules([ + 'trade_no' => 'nullable|string|max:64|required_without:out_trade_no', + 'out_trade_no' => 'nullable|string|max:64|required_without:trade_no', + ]); + } + + /** + * 关闭订单场景。 + * + * @return static + */ + public function sceneClose(): static + { + return $this->sceneQuery(); + } + + /** + * 退款查询场景。 + * + * @return static + */ + public function sceneRefundQuery(): static + { + return $this->appendRules([ + 'refund_no' => 'nullable|string|max:64|required_without:out_refund_no', + 'out_refund_no' => 'nullable|string|max:64|required_without:refund_no', + ]); + } + + /** + * 转账查询场景。 + * + * @return static + */ + public function sceneTransferQuery(): static + { + return $this->appendRules([ + 'biz_no' => 'nullable|string|max:32|required_without:out_biz_no', + 'out_biz_no' => 'nullable|string|max:64|required_without:biz_no', + ]); + } + + /** + * 转账发起场景。 + * + * @return static + */ + public function sceneTransferSubmit(): static + { + return $this->appendRules([ + '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', + 'sign' => 'required|string|max:2048', + ]); + } +} diff --git a/app/http/mer/controller/file/FileRecordController.php b/app/http/mer/controller/file/FileRecordController.php new file mode 100644 index 0000000..bab8a1a --- /dev/null +++ b/app/http/mer/controller/file/FileRecordController.php @@ -0,0 +1,103 @@ +validated(array_merge($this->payload($request), ['scene' => $request->input('scene')]), FileRecordValidator::class, 'store'); + $uploadedFile = $request->file('file'); + if ($uploadedFile === null) { + throw new ValidationException('请先选择上传文件'); + } + + $createdBy = $this->currentMerchantId($request); + $createdByName = $this->currentMerchantNo($request); + + if (is_array($uploadedFile)) { + $items = []; + foreach ($uploadedFile as $file) { + if ($file instanceof UploadFile) { + $items[] = $this->fileRecordService->upload($file, $data, $createdBy, $createdByName); + } + } + + if ($items === []) { + throw new ValidationException('上传文件无效'); + } + + return $this->success([ + 'list' => $items, + 'total' => count($items), + ]); + } + + if (!$uploadedFile instanceof UploadFile) { + throw new ValidationException('上传文件无效'); + } + + return $this->success($this->fileRecordService->upload($uploadedFile, $data, $createdBy, $createdByName)); + } + + /** + * 获取文件预览响应。 + * + * @param Request $request 请求对象 + * @param string $id 文件记录ID + * @return Response 响应对象 + */ + public function preview(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'preview'); + + return $this->fileRecordService->previewResponse((int) $data['id']); + } + + /** + * 获取文件下载响应。 + * + * @param Request $request 请求对象 + * @param string $id 文件记录ID + * @return Response 响应对象 + */ + public function download(Request $request, string $id): Response + { + $data = $this->validated(['id' => (int) $id], FileRecordValidator::class, 'download'); + + return $this->fileRecordService->downloadResponse((int) $data['id']); + } +} diff --git a/app/http/mer/controller/merchant/MerchantPortalController.php b/app/http/mer/controller/merchant/MerchantPortalController.php index e8771d3..2180321 100644 --- a/app/http/mer/controller/merchant/MerchantPortalController.php +++ b/app/http/mer/controller/merchant/MerchantPortalController.php @@ -100,6 +100,138 @@ class MerchantPortalController extends BaseController return $this->success($this->merchantPortalService->myChannels($payload, $merchantId, $page, $pageSize)); } + public function channelCreateMeta(Request $request): Response + { + return $this->success($this->merchantPortalService->channelCreateMeta()); + } + + public function createChannel(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'channelStore'); + + return $this->success($this->merchantPortalService->createChannel($merchantId, $data)); + } + + public function updateChannel(Request $request, string $id): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated( + array_merge($this->payload($request), ['id' => (int) $id]), + MerchantPortalValidator::class, + 'channelUpdate' + ); + + $channel = $this->merchantPortalService->updateChannel($merchantId, (int) $data['id'], $data); + if (!$channel) { + return $this->fail('通道不存在', 404); + } + + return $this->success($channel); + } + + public function deleteChannel(Request $request, string $id): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated(['id' => (int) $id], MerchantPortalValidator::class, 'channelDestroy'); + if (!$this->merchantPortalService->deleteChannel($merchantId, (int) $data['id'])) { + return $this->fail('通道不存在', 404); + } + + return $this->success(true); + } + + public function pluginConfigs(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $payload = $this->validated($this->payload($request), MerchantPortalValidator::class, 'pluginConfigIndex'); + $page = max(1, (int) ($payload['page'] ?? 1)); + $pageSize = max(1, (int) ($payload['page_size'] ?? 10)); + + return $this->success($this->merchantPortalService->pluginConfigs($payload, $merchantId, $page, $pageSize)); + } + + public function createPluginConfig(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'pluginConfigStore'); + + return $this->success($this->merchantPortalService->createPluginConfig($merchantId, $data)); + } + + public function updatePluginConfig(Request $request, string $id): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated( + array_merge($this->payload($request), ['id' => (int) $id]), + MerchantPortalValidator::class, + 'pluginConfigUpdate' + ); + + $config = $this->merchantPortalService->updatePluginConfig($merchantId, (int) $data['id'], $data); + if (!$config) { + return $this->fail('插件配置不存在', 404); + } + + return $this->success($config); + } + + public function deletePluginConfig(Request $request, string $id): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + $data = $this->validated(['id' => (int) $id], MerchantPortalValidator::class, 'pluginConfigDestroy'); + if (!$this->merchantPortalService->deletePluginConfig($merchantId, (int) $data['id'])) { + return $this->fail('插件配置不存在', 404); + } + + return $this->success(true); + } + + public function pluginConfigOptions(Request $request): Response + { + $merchantId = $this->currentMerchantId($request); + if ($merchantId <= 0) { + return $this->fail('未获取到当前商户信息', 401); + } + + return $this->success([ + 'configs' => $this->merchantPortalService->pluginConfigOptions($merchantId, (string) $request->get('plugin_code', '')), + ]); + } + + public function pluginSchema(Request $request, string $code): Response + { + return $this->success($this->merchantPortalService->pluginSchema($code)); + } + /** * 获取路由解析结果。 * @@ -113,7 +245,12 @@ class MerchantPortalController extends BaseController return $this->fail('未获取到当前商户信息', 401); } - $payload = $this->validated($this->payload($request), MerchantPortalValidator::class, 'routePreview'); + $rawPayload = $this->payload($request); + if (empty($rawPayload['pay_type_id']) || empty($rawPayload['pay_amount'])) { + return $this->success($this->merchantPortalService->routePreview($merchantId, 0, 0)); + } + + $payload = $this->validated($rawPayload, MerchantPortalValidator::class, 'routePreview'); $payTypeId = (int) ($payload['pay_type_id'] ?? 0); $payAmount = (int) ($payload['pay_amount'] ?? 0); $statDate = trim((string) ($payload['stat_date'] ?? '')); @@ -150,7 +287,9 @@ class MerchantPortalController extends BaseController return $this->fail('未获取到当前商户信息', 401); } - return $this->success($this->merchantPortalService->issueCredential($merchantId)); + $data = $this->validated($this->payload($request), MerchantPortalValidator::class, 'issueCredential'); + + return $this->success($this->merchantPortalService->issueCredential($merchantId, $data)); } /** diff --git a/app/http/mer/middleware/MerchantAuthMiddleware.php b/app/http/mer/middleware/MerchantAuthMiddleware.php index e3658de..fe6a0fb 100644 --- a/app/http/mer/middleware/MerchantAuthMiddleware.php +++ b/app/http/mer/middleware/MerchantAuthMiddleware.php @@ -2,6 +2,7 @@ namespace app\http\mer\middleware; +use app\exception\UnauthorizedException; use app\service\merchant\auth\MerchantAuthService; use support\Context; use Webman\Http\Request; @@ -34,6 +35,7 @@ class MerchantAuthMiddleware implements MiddlewareInterface * @param Request $request 请求对象 * @param callable $handler handler * @return Response 响应对象 + * @throws UnauthorizedException */ public function process(Request $request, callable $handler): Response { @@ -42,11 +44,7 @@ class MerchantAuthMiddleware implements MiddlewareInterface if ($token === '') { if ((int) env('AUTH_MIDDLEWARE_STRICT', 1) === 1) { - return json([ - 'code' => 401, - 'msg' => 'merchant unauthorized', - 'data' => null, - ]); + throw new UnauthorizedException('商户未授权'); } } else { $result = $this->merchantAuthService->authenticateToken( @@ -55,11 +53,7 @@ class MerchantAuthMiddleware implements MiddlewareInterface $request->header('user-agent', '') ); if (!$result) { - return json([ - 'code' => 401, - 'msg' => 'merchant unauthorized', - 'data' => null, - ]); + throw new UnauthorizedException('商户未授权'); } Context::set('auth.merchant_id', (int) $result['merchant']->id); diff --git a/app/http/mer/validation/MerchantPortalValidator.php b/app/http/mer/validation/MerchantPortalValidator.php index 096192e..0a8a475 100644 --- a/app/http/mer/validation/MerchantPortalValidator.php +++ b/app/http/mer/validation/MerchantPortalValidator.php @@ -29,6 +29,26 @@ class MerchantPortalValidator extends Validator 'pay_type_id' => 'required|integer|min:1', 'pay_amount' => 'required|integer|min:1', 'stat_date' => 'sometimes|date', + 'id' => 'sometimes|integer|min:1', + 'keyword' => 'sometimes|string|max:128', + 'plugin_code' => 'sometimes|string|alpha_dash|min:2|max:32', + 'config' => 'nullable|array', + 'settlement_cycle_type' => 'sometimes|integer|in:0,1,2,3,4', + 'settlement_cutoff_time' => 'nullable|date_format:H:i:s', + 'name' => 'sometimes|string|min:2|max:128', + 'api_config_id' => 'sometimes|integer|min:1', + 'daily_limit_amount' => 'nullable|integer|min:0', + 'daily_limit_count' => 'nullable|integer|min:0', + 'min_amount' => 'nullable|integer|min:0', + 'max_amount' => 'nullable|integer|min:0', + 'remark' => 'nullable|string|max:500', + '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', ]; /** @@ -51,6 +71,26 @@ class MerchantPortalValidator extends Validator 'pay_type_id' => '支付方式', 'pay_amount' => '支付金额', 'stat_date' => '统计日期', + 'id' => '记录ID', + 'keyword' => '关键字', + 'plugin_code' => '支付插件', + 'config' => '插件配置', + 'settlement_cycle_type' => '结算周期', + 'settlement_cutoff_time' => '结算截止时间', + 'name' => '通道名称', + 'api_config_id' => '插件配置', + 'daily_limit_amount' => '单日限额', + 'daily_limit_count' => '单日限笔', + 'min_amount' => '单笔最小金额', + 'max_amount' => '单笔最大金额', + 'remark' => '备注', + 'status' => '状态', + 'rotate_v1' => 'V1 凭证', + 'rotate_v2' => 'V2 凭证', + 'sign_type' => '签名类型', + 'sort_no' => '排序', + 'page' => '页码', + 'page_size' => '每页条数', ]; /** @@ -71,7 +111,47 @@ class MerchantPortalValidator extends Validator ], 'passwordUpdate' => ['current_password', 'password', 'password_confirm'], 'routePreview' => ['pay_type_id', 'pay_amount', 'stat_date'], + 'pluginConfigIndex' => ['keyword', 'plugin_code', 'page', 'page_size'], + 'pluginConfigStore' => ['plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], + 'pluginConfigUpdate' => ['id', 'plugin_code', 'config', 'settlement_cycle_type', 'settlement_cutoff_time', 'remark'], + 'pluginConfigDestroy' => ['id'], + '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'], ]; + + public function rules(): array + { + $rules = parent::rules(); + + return match ($this->scene()) { + 'pluginConfigStore' => array_merge($rules, [ + 'plugin_code' => 'required|string|alpha_dash|min:2|max:32', + ]), + 'pluginConfigUpdate' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'plugin_code' => 'required|string|alpha_dash|min:2|max:32', + ]), + 'pluginConfigDestroy', 'channelDestroy' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + ]), + 'channelStore' => array_merge($rules, [ + 'name' => 'required|string|min:2|max:128', + 'pay_type_id' => 'required|integer|min:1', + 'plugin_code' => 'required|string|alpha_dash|min:2|max:32', + 'api_config_id' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1', + ]), + 'channelUpdate' => array_merge($rules, [ + 'id' => 'required|integer|min:1', + 'name' => 'required|string|min:2|max:128', + 'pay_type_id' => 'required|integer|min:1', + 'plugin_code' => 'required|string|alpha_dash|min:2|max:32', + 'api_config_id' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1', + ]), + default => $rules, + }; + } } - - diff --git a/app/listener/PaymentMerchantNotifyListener.php b/app/listener/PaymentMerchantNotifyListener.php new file mode 100644 index 0000000..76ea36b --- /dev/null +++ b/app/listener/PaymentMerchantNotifyListener.php @@ -0,0 +1,124 @@ + $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('[PaymentMerchantNotifyListener] 支付成功事件缺少可用支付单'); + return; + } + + $this->merchantNotifyDispatcherService->enqueueAndDispatchPaySuccess($payOrder); + } catch (\Throwable $e) { + Log::warning(sprintf( + '[PaymentMerchantNotifyListener] 商户支付通知创建失败 event=%s pay_no=%s error=%s', + $eventName, + (string) ($payload['pay_no'] ?? ''), + $e->getMessage() + )); + } + } + + /** + * 退款成功后创建并尝试派发商户通知。 + * + * @param array $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onRefundOrderSucceeded(array $payload = [], string $eventName = ''): void + { + try { + $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) { + Log::warning('[PaymentMerchantNotifyListener] 退款成功事件缺少可用退款单'); + return; + } + + $this->merchantNotifyDispatcherService->enqueueAndDispatchRefundSuccess($refundOrder); + } catch (\Throwable $e) { + Log::warning(sprintf( + '[PaymentMerchantNotifyListener] 商户退款通知创建失败 event=%s refund_no=%s error=%s', + $eventName, + (string) ($payload['refund_no'] ?? ''), + $e->getMessage() + )); + } + } + + /** + * 清算成功后创建并尝试派发商户通知。 + * + * @param array $payload 事件载荷 + * @param string $eventName 事件名称 + * @return void + */ + public function onSettlementOrderSucceeded(array $payload = [], string $eventName = ''): void + { + try { + $settlementOrder = $payload['settlement_order'] ?? null; + if (!$settlementOrder instanceof SettlementOrder) { + $settleNo = trim((string) ($payload['settle_no'] ?? '')); + $settlementOrder = $settleNo !== '' ? $this->settlementOrderRepository->findBySettleNo($settleNo) : null; + } + + if (!$settlementOrder instanceof SettlementOrder) { + Log::warning('[PaymentMerchantNotifyListener] 清算成功事件缺少可用清算单'); + return; + } + + $this->merchantNotifyDispatcherService->enqueueAndDispatchSettlementSuccess($settlementOrder); + } catch (\Throwable $e) { + Log::warning(sprintf( + '[PaymentMerchantNotifyListener] 商户清算通知创建失败 event=%s settle_no=%s error=%s', + $eventName, + (string) ($payload['settle_no'] ?? ''), + $e->getMessage() + )); + } + } +} diff --git a/app/listener/SystemConfigChangedListener.php b/app/listener/SystemConfigChangedListener.php index bc3e27e..7e07053 100644 --- a/app/listener/SystemConfigChangedListener.php +++ b/app/listener/SystemConfigChangedListener.php @@ -3,6 +3,7 @@ namespace app\listener; use app\service\system\config\SystemConfigRuntimeService; +use support\Log; /** * 系统配置变更监听器。 @@ -31,10 +32,18 @@ class SystemConfigChangedListener */ public function refreshRuntimeCache(array $payload = [], string $eventName = ''): void { - $this->systemConfigRuntimeService->refresh(); + try { + $this->systemConfigRuntimeService->refresh(); + } catch (\Throwable $e) { + Log::warning(sprintf( + '[SystemConfigChangedListener] 系统配置运行时缓存刷新失败 event=%s group_code=%s error=%s', + $eventName, + (string) ($payload['group_code'] ?? ''), + $e->getMessage() + )); + } } } - diff --git a/app/model/admin/ChannelNotifyLog.php b/app/model/admin/ChannelNotifyLog.php index 4ee1747..25c5223 100644 --- a/app/model/admin/ChannelNotifyLog.php +++ b/app/model/admin/ChannelNotifyLog.php @@ -55,6 +55,7 @@ class ChannelNotifyLog extends BaseModel protected $casts = [ 'channel_id' => 'integer', 'notify_type' => 'integer', + 'raw_payload' => 'array', 'verify_status' => 'integer', 'process_status' => 'integer', 'retry_count' => 'integer', @@ -65,4 +66,3 @@ class ChannelNotifyLog extends BaseModel } - diff --git a/app/model/admin/PayCallbackLog.php b/app/model/admin/PayCallbackLog.php index a2c235d..5a92fa8 100644 --- a/app/model/admin/PayCallbackLog.php +++ b/app/model/admin/PayCallbackLog.php @@ -34,9 +34,11 @@ class PayCallbackLog extends BaseModel 'channel_id', 'callback_type', 'request_data', + 'request_hash', 'verify_status', 'process_status', 'process_result', + 'created_at', ]; /** @@ -57,11 +59,12 @@ class PayCallbackLog extends BaseModel protected $casts = [ 'channel_id' => 'integer', 'callback_type' => 'integer', + 'request_data' => 'array', + 'request_hash' => 'string', 'verify_status' => 'integer', 'process_status' => 'integer', + 'process_result' => 'array', 'created_at' => 'datetime', ]; } - - diff --git a/app/model/merchant/Merchant.php b/app/model/merchant/Merchant.php index c21fbe9..caee274 100644 --- a/app/model/merchant/Merchant.php +++ b/app/model/merchant/Merchant.php @@ -37,6 +37,9 @@ class Merchant extends BaseModel 'settlement_account_no', 'settlement_bank_name', 'settlement_bank_branch', + 'pay_status', + 'settle_status', + 'settle_type', 'status', 'last_login_at', 'last_login_ip', @@ -62,6 +65,9 @@ class Merchant extends BaseModel 'merchant_type' => 'integer', 'group_id' => 'integer', 'risk_level' => 'integer', + 'pay_status' => 'integer', + 'settle_status' => 'integer', + 'settle_type' => 'integer', 'status' => 'integer', 'last_login_at' => 'datetime', 'password_updated_at' => 'datetime', @@ -70,4 +76,3 @@ class Merchant extends BaseModel ]; } - diff --git a/app/model/merchant/MerchantApiCredential.php b/app/model/merchant/MerchantApiCredential.php index 5f8839e..7154586 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 { @@ -26,6 +26,7 @@ class MerchantApiCredential extends BaseModel 'merchant_id', 'sign_type', 'api_key', + 'merchant_public_key', 'status', 'last_used_at', ]; @@ -53,4 +54,3 @@ class MerchantApiCredential extends BaseModel 'updated_at' => 'datetime', ]; } - diff --git a/app/model/payment/BizOrder.php b/app/model/payment/BizOrder.php index 1137d72..7089856 100644 --- a/app/model/payment/BizOrder.php +++ b/app/model/payment/BizOrder.php @@ -6,7 +6,7 @@ use app\common\base\BaseModel; /** * 业务订单模型。 - * 表示商户业务侧原始订单,支付单和退款单都从这里展开。 + * 表示商户业务侧原始订单,只保留业务事实与收银台恢复所需字段。 */ class BizOrder extends BaseModel { @@ -26,11 +26,13 @@ class BizOrder extends BaseModel 'biz_no', 'trace_no', 'merchant_id', - 'merchant_group_id', - 'poll_group_id', 'merchant_order_no', 'subject', 'body', + 'notify_url', + 'return_url', + 'client_ip', + 'device', 'order_amount', 'paid_amount', 'refund_amount', @@ -52,8 +54,10 @@ class BizOrder extends BaseModel */ protected $casts = [ 'merchant_id' => 'integer', - 'merchant_group_id' => 'integer', - 'poll_group_id' => 'integer', + 'notify_url' => 'string', + 'return_url' => 'string', + 'client_ip' => 'string', + 'device' => 'string', 'order_amount' => 'integer', 'paid_amount' => 'integer', 'refund_amount' => 'integer', @@ -70,6 +74,3 @@ class BizOrder extends BaseModel ]; } - - - diff --git a/app/model/payment/NotifyTask.php b/app/model/payment/NotifyTask.php index 895f51f..8e6fe77 100644 --- a/app/model/payment/NotifyTask.php +++ b/app/model/payment/NotifyTask.php @@ -24,6 +24,8 @@ class NotifyTask extends BaseModel */ protected $fillable = [ 'notify_no', + 'event_type', + 'ref_no', 'merchant_id', 'merchant_group_id', 'biz_no', @@ -53,8 +55,11 @@ class NotifyTask extends BaseModel * @var mixed */ protected $casts = [ + 'event_type' => 'string', + 'ref_no' => 'string', 'merchant_id' => 'integer', 'merchant_group_id' => 'integer', + 'notify_data' => 'array', 'status' => 'integer', 'retry_count' => 'integer', 'next_retry_at' => 'datetime', @@ -65,5 +70,3 @@ class NotifyTask extends BaseModel } - - diff --git a/app/model/payment/PayOrder.php b/app/model/payment/PayOrder.php index 45ef8b2..ebde151 100644 --- a/app/model/payment/PayOrder.php +++ b/app/model/payment/PayOrder.php @@ -36,6 +36,10 @@ class PayOrder extends BaseModel 'channel_type', 'channel_mode', 'pay_amount', + 'notify_url', + 'return_url', + 'client_ip', + 'device', 'fee_rate_bp_snapshot', 'split_rate_bp_snapshot', 'fee_estimated_amount', @@ -74,6 +78,10 @@ class PayOrder extends BaseModel 'channel_type' => 'integer', 'channel_mode' => 'integer', 'pay_amount' => 'integer', + 'notify_url' => 'string', + 'return_url' => 'string', + 'client_ip' => 'string', + 'device' => 'string', 'fee_rate_bp_snapshot' => 'integer', 'split_rate_bp_snapshot' => 'integer', 'fee_estimated_amount' => 'integer', @@ -96,5 +104,3 @@ class PayOrder extends BaseModel } - - diff --git a/app/model/payment/PaymentPlugin.php b/app/model/payment/PaymentPlugin.php index 5fff4fe..1fd2f43 100644 --- a/app/model/payment/PaymentPlugin.php +++ b/app/model/payment/PaymentPlugin.php @@ -53,6 +53,7 @@ class PaymentPlugin extends BaseModel 'version', 'author', 'link', + 'allow_merchant', 'status', 'remark', ]; @@ -66,6 +67,7 @@ class PaymentPlugin extends BaseModel 'config_schema' => 'array', 'pay_types' => 'array', 'transfer_types' => 'array', + 'allow_merchant' => 'integer', 'status' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', @@ -74,4 +76,3 @@ class PaymentPlugin extends BaseModel - diff --git a/app/model/payment/PaymentPluginConf.php b/app/model/payment/PaymentPluginConf.php index 7cd82aa..0210ed8 100644 --- a/app/model/payment/PaymentPluginConf.php +++ b/app/model/payment/PaymentPluginConf.php @@ -23,6 +23,7 @@ class PaymentPluginConf extends BaseModel * @var mixed */ protected $fillable = [ + 'merchant_id', 'plugin_code', 'config', 'settlement_cycle_type', @@ -36,6 +37,7 @@ class PaymentPluginConf extends BaseModel * @var mixed */ protected $casts = [ + 'merchant_id' => 'integer', 'config' => 'array', 'settlement_cycle_type' => 'integer', 'created_at' => 'datetime', @@ -45,4 +47,3 @@ class PaymentPluginConf extends BaseModel - diff --git a/app/model/payment/TransferOrder.php b/app/model/payment/TransferOrder.php new file mode 100644 index 0000000..136e380 --- /dev/null +++ b/app/model/payment/TransferOrder.php @@ -0,0 +1,57 @@ + 'integer', + 'merchant_group_id' => 'integer', + 'channel_id' => 'integer', + 'amount' => 'integer', + 'cost_amount' => 'integer', + 'status' => 'integer', + 'request_at' => 'datetime', + 'processing_at' => 'datetime', + 'succeeded_at' => 'datetime', + 'failed_at' => 'datetime', + 'ext_json' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} + diff --git a/app/process/PaymentRuntimeProcess.php b/app/process/PaymentRuntimeProcess.php new file mode 100644 index 0000000..e9f18d7 --- /dev/null +++ b/app/process/PaymentRuntimeProcess.php @@ -0,0 +1,221 @@ + + */ + 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 + { + $heartbeat = $this->intOption('heartbeat_seconds', 5, 1, 60); + + Timer::add($heartbeat, function (): void { + $this->tick(); + }); + + Log::info(sprintf('[PaymentRuntimeProcess] 支付运行时维护进程已启动 heartbeat=%s', $heartbeat)); + } + + /** + * 心跳调度入口。 + * + * @return void + */ + private function tick(): void + { + try { + if (!$this->boolConfig('pay_runtime_enabled', true)) { + return; + } + + $this->runIfDue( + 'notify_retry', + $this->intConfig('pay_notify_retry_scan_interval_seconds', 60, 5), + fn (): array => $this->maintenanceService()->retryMerchantNotifies( + $this->intConfig('pay_notify_retry_batch_size', 100, 1) + ) + ); + + $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( + 'active_query', + $this->intConfig('pay_active_query_interval_seconds', 60, 10), + fn (): array => $this->maintenanceService()->syncPayingOrdersByQuery( + $this->intConfig('pay_active_query_batch_size', 50, 1), + $this->intConfig('pay_active_query_min_age_seconds', 60, 1) + ) + ); + } + } catch (\Throwable $e) { + Log::warning('[PaymentRuntimeProcess] 心跳调度失败:' . $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( + '[PaymentRuntimeProcess] %s 执行完成 %s', + $key, + json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + )); + } + } catch (\Throwable $e) { + Log::warning(sprintf('[PaymentRuntimeProcess] %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 PaymentRuntimeMaintenanceService 维护服务 + */ + private function maintenanceService(): PaymentRuntimeMaintenanceService + { + return container_make(PaymentRuntimeMaintenanceService::class, []); + } + + /** + * 读取布尔系统配置。 + * + * @param string $key 配置键 + * @param bool $default 默认值 + * @return bool 配置值 + */ + private function boolConfig(string $key, bool $default): bool + { + $value = strtolower(trim($this->runtimeConfig()->get($key, $default ? '1' : '0'))); + + return in_array($value, ['1', 'true', 'yes', 'on', 'enabled'], true); + } + + /** + * 读取整数系统配置。 + * + * @param string $key 配置键 + * @param int $default 默认值 + * @param int $min 最小值 + * @return int 配置值 + */ + private function intConfig(string $key, int $default, int $min = 1): int + { + $value = (int) $this->runtimeConfig()->get($key, $default); + + return max($min, $value); + } + + /** + * 读取整数构造选项。 + * + * @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 SystemConfigRuntimeService 运行时配置服务 + */ + private function runtimeConfig(): SystemConfigRuntimeService + { + return container_make(SystemConfigRuntimeService::class, []); + } +} diff --git a/app/repository/payment/config/PaymentChannelRepository.php b/app/repository/payment/config/PaymentChannelRepository.php index 9f8856a..69a7a49 100644 --- a/app/repository/payment/config/PaymentChannelRepository.php +++ b/app/repository/payment/config/PaymentChannelRepository.php @@ -73,6 +73,27 @@ class PaymentChannelRepository extends BaseRepository return $query->exists(); } + /** + * 判断指定商户的通道名称是否已存在。 + * + * @param int $merchantId 商户ID + * @param string $name 通道名称 + * @param int $ignoreId 需要排除的记录ID + * @return bool 是否存在 + */ + public function existsByMerchantName(int $merchantId, string $name, int $ignoreId = 0): bool + { + $query = $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('name', $name); + + if ($ignoreId > 0) { + $query->where('id', '<>', $ignoreId); + } + + return $query->exists(); + } + /** * 统计商户名下的支付通道概览。 * @@ -104,4 +125,3 @@ class PaymentChannelRepository extends BaseRepository } - diff --git a/app/repository/payment/config/PaymentPluginConfRepository.php b/app/repository/payment/config/PaymentPluginConfRepository.php index 85de946..f99aadb 100644 --- a/app/repository/payment/config/PaymentPluginConfRepository.php +++ b/app/repository/payment/config/PaymentPluginConfRepository.php @@ -32,14 +32,30 @@ class PaymentPluginConfRepository extends BaseRepository public function findByPluginCode(string $pluginCode, array $columns = ['*']) { return $this->model->newQuery() + ->where('merchant_id', 0) ->where('plugin_code', $pluginCode) ->orderByDesc('id') ->first($columns); } + + /** + * 查询当前商户可访问的插件配置。 + * + * @param int $merchantId 商户ID + * @param int $id 配置ID + * @param array $columns 字段列表 + * @return PaymentPluginConf|null 插件配置记录 + */ + public function findByMerchantAndId(int $merchantId, int $id, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->whereKey($id) + ->first($columns); + } } - diff --git a/app/repository/payment/config/PaymentPluginRepository.php b/app/repository/payment/config/PaymentPluginRepository.php index 80dae2a..9f0e199 100644 --- a/app/repository/payment/config/PaymentPluginRepository.php +++ b/app/repository/payment/config/PaymentPluginRepository.php @@ -49,10 +49,40 @@ class PaymentPluginRepository extends BaseRepository ->orderBy('code', 'asc') ->get($columns); } + + /** + * 获取商户端允许使用的支付插件。 + * + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 插件列表 + */ + public function merchantEnabledList(array $columns = ['*']) + { + return $this->model->newQuery() + ->where('status', 1) + ->where('allow_merchant', 1) + ->orderBy('code', 'asc') + ->get($columns); + } + + /** + * 查询商户端允许使用的支付插件。 + * + * @param string $code 插件编码 + * @param array $columns 字段列表 + * @return PaymentPlugin|null 插件记录 + */ + public function findMerchantAllowed(string $code, array $columns = ['*']): ?PaymentPlugin + { + return $this->model->newQuery() + ->whereKey($code) + ->where('status', 1) + ->where('allow_merchant', 1) + ->first($columns); + } } - diff --git a/app/repository/payment/notify/NotifyTaskRepository.php b/app/repository/payment/notify/NotifyTaskRepository.php index b55e43b..cf4208f 100644 --- a/app/repository/payment/notify/NotifyTaskRepository.php +++ b/app/repository/payment/notify/NotifyTaskRepository.php @@ -36,6 +36,37 @@ class NotifyTaskRepository extends BaseRepository ->first($columns); } + /** + * 根据通知事件和引用单号查询通知任务。 + * + * @param string $eventType 通知事件类型 + * @param string $refNo 事件引用单号 + * @param array $columns 字段列表 + * @return NotifyTask|null 通知任务记录 + */ + public function findByEventRef(string $eventType, string $refNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('event_type', $eventType) + ->where('ref_no', $refNo) + ->first($columns); + } + + /** + * 查询指定支付单的通知任务列表。 + * + * @param string $payNo 支付单号 + * @param array $columns 字段列表 + * @return \Illuminate\Database\Eloquent\Collection 通知任务列表 + */ + public function listByPayNo(string $payNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('pay_no', $payNo) + ->orderByDesc('id') + ->get($columns); + } + /** * 查询可重试的通知任务列表。 * @@ -47,6 +78,8 @@ class NotifyTaskRepository extends BaseRepository { return $this->model->newQuery() ->where('status', $status) + ->whereNotNull('next_retry_at') + ->where('next_retry_at', '<=', date('Y-m-d H:i:s')) ->orderBy('next_retry_at') ->get($columns); } @@ -54,6 +87,3 @@ class NotifyTaskRepository extends BaseRepository - - - diff --git a/app/repository/payment/trade/PayOrderRepository.php b/app/repository/payment/trade/PayOrderRepository.php index 820de73..7b62b46 100644 --- a/app/repository/payment/trade/PayOrderRepository.php +++ b/app/repository/payment/trade/PayOrderRepository.php @@ -3,6 +3,7 @@ namespace app\repository\payment\trade; use app\common\base\BaseRepository; +use app\common\constant\TradeConstant; use app\model\payment\PayOrder; /** @@ -159,6 +160,47 @@ class PayOrderRepository extends BaseRepository ->first($columns); } + /** + * 查询已过期但还未进入终态的支付单。 + * + * @param string $now 当前时间 + * @param int $limit 限制条数 + * @return \Illuminate\Database\Eloquent\Collection 支付单列表 + */ + public function listExpiredMutable(string $now, int $limit = 100) + { + return $this->model->newQuery() + ->whereIn('status', TradeConstant::orderMutableStatuses()) + ->whereNotNull('expire_at') + ->where('expire_at', '<=', $now) + ->orderBy('expire_at') + ->orderBy('id') + ->limit(max(1, $limit)) + ->get(); + } + + /** + * 查询需要主动查单的支付中订单。 + * + * @param string $before 最早拉起时间 + * @param int $limit 限制条数 + * @return \Illuminate\Database\Eloquent\Collection 支付单列表 + */ + public function listPayingForActiveQuery(string $before, int $limit = 50) + { + return $this->model->newQuery() + ->where('status', TradeConstant::ORDER_STATUS_PAYING) + ->where('request_at', '<=', $before) + ->where(function ($query) { + $query->whereNull('expire_at') + ->orWhere('expire_at', '>', date('Y-m-d H:i:s')); + }) + ->orderBy('request_at') + ->orderBy('id') + ->limit(max(1, $limit)) + ->get(); + } + /** * 查询商户最近支付单列表,用于总览展示。 * @@ -189,4 +231,3 @@ class PayOrderRepository extends BaseRepository - diff --git a/app/repository/payment/trade/RefundOrderRepository.php b/app/repository/payment/trade/RefundOrderRepository.php index cbfd426..b62d09a 100644 --- a/app/repository/payment/trade/RefundOrderRepository.php +++ b/app/repository/payment/trade/RefundOrderRepository.php @@ -107,6 +107,7 @@ class RefundOrderRepository extends BaseRepository { return $this->model->newQuery() ->where('pay_no', $payNo) + ->orderByDesc('id') ->first($columns); } @@ -151,6 +152,7 @@ class RefundOrderRepository extends BaseRepository { return $this->model->newQuery() ->where('pay_no', $payNo) + ->orderByDesc('id') ->lockForUpdate() ->first($columns); } @@ -171,5 +173,3 @@ class RefundOrderRepository extends BaseRepository - - diff --git a/app/repository/payment/trade/TransferOrderRepository.php b/app/repository/payment/trade/TransferOrderRepository.php new file mode 100644 index 0000000..7b60cb6 --- /dev/null +++ b/app/repository/payment/trade/TransferOrderRepository.php @@ -0,0 +1,50 @@ +model->newQuery() + ->where('biz_no', $bizNo) + ->first($columns); + } + + public function findByOutBizNo(int $merchantId, string $outBizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('out_biz_no', $outBizNo) + ->first($columns); + } + + public function findForUpdateByBizNo(string $bizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('biz_no', $bizNo) + ->lockForUpdate() + ->first($columns); + } + + public function findForUpdateByOutBizNo(int $merchantId, string $outBizNo, array $columns = ['*']) + { + return $this->model->newQuery() + ->where('merchant_id', $merchantId) + ->where('out_biz_no', $outBizNo) + ->lockForUpdate() + ->first($columns); + } +} + diff --git a/app/route/admin.php b/app/route/admin.php index 947e340..71d9d26 100644 --- a/app/route/admin.php +++ b/app/route/admin.php @@ -8,6 +8,7 @@ use app\http\admin\controller\account\MerchantAccountController; use app\http\admin\controller\account\MerchantAccountLedgerController; 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; @@ -39,6 +40,7 @@ Route::group('/adminapi', function () { 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' => '商户选项']); @@ -144,6 +146,7 @@ Route::group('/adminapi', function () { Route::delete('/file-asset/{id}', [FileRecordController::class, 'destroy'])->name('adminApiFileRecordDestroy')->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' => '退款重试']); @@ -157,6 +160,10 @@ Route::group('/adminapi', function () { 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::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::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' => '资金账户详情']); diff --git a/app/route/api.php b/app/route/api.php index 16716c9..686dfc2 100644 --- a/app/route/api.php +++ b/app/route/api.php @@ -2,59 +2,69 @@ use Webman\Route; use app\common\middleware\Cors; -use app\http\api\controller\adapter\EpayController; -use app\http\api\controller\notify\NotifyController; -use app\http\api\controller\trade\PayController; -use app\http\api\controller\trade\RefundController; -use app\http\api\controller\route\RouteController; -use app\http\api\controller\settlement\SettlementController; -use app\http\api\controller\trace\TraceController; +use app\http\api\controller\cashier\CashierController; +use app\http\api\controller\epay\EpayV1Controller; +use app\http\api\controller\epay\EpayV2Controller; -Route::any('/pay[/{path:.+}]', function () { - return view('/public/cashier/index'); +$serveCashierApp = static function () { + $indexPath = public_path('cashier/index.html'); + if (!is_file($indexPath)) { + return response('Cashier page not found', 404); + } + + return response(file_get_contents($indexPath), 200, [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); +}; + +// 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' => '收银台详情页']); }); -Route::group('', function () { - Route::any('/submit.php', [EpayController::class, 'submit'])->name('epaySubmit')->setParams(['real_name' => 'Epay页面跳转支付']); - Route::post('/mapi.php', [EpayController::class, 'mapi'])->name('epayMapi')->setParams(['real_name' => 'Epay接口支付']); - Route::any('/api.php', [EpayController::class, 'api'])->name('epayApi')->setParams(['real_name' => 'Epay标准API']); -})->middleware([Cors::class]); +Route::group('/payment', function () use ($serveCashierApp) { + Route::get('', $serveCashierApp)->name('paymentIndex')->setParams(['real_name' => '支付页首页']); + Route::any('/{path:.+}', $serveCashierApp)->name('paymentDetail')->setParams(['real_name' => '支付页详情']); +}); +// ePay V2 新版接口 Route::group('/api', function () { + // 支付模块 Route::group('/pay', function () { - Route::post('/prepare', [PayController::class, 'prepare'])->name('payPrepare')->setParams(['real_name' => '支付预下单']); - Route::get('/{payNo}', [PayController::class, 'show'])->name('payDetail')->setParams(['real_name' => '查询支付单']); - Route::post('/{payNo}/close', [PayController::class, 'close'])->name('payClose')->setParams(['real_name' => '关闭支付单']); - Route::post('/{payNo}/timeout', [PayController::class, 'timeout'])->name('payTimeout')->setParams(['real_name' => '支付超时']); - Route::any('/{payNo}/callback', [PayController::class, 'callback'])->name('payChannelCallback')->setParams(['real_name' => '第三方支付回调']); - Route::post('/callback/mock', [PayController::class, 'callback'])->name('payCallbackMock')->setParams(['real_name' => '支付回调模拟入口']); + // 文档约定是 POST,同时兼容旧版 SDK `getPayLink()` 生成的 GET 请求。 + Route::any('/submit', [EpayV2Controller::class, 'submit'])->name('epayV2PaySubmit')->setParams(['real_name' => 'ePay V2 页面跳转支付']); + Route::post('/create', [EpayV2Controller::class, 'create'])->name('epayV2PayCreate')->setParams(['real_name' => 'ePay V2 创建订单']); + Route::post('/query', [EpayV2Controller::class, 'query'])->name('epayV2PayQuery')->setParams(['real_name' => 'ePay V2 查询订单']); + 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('/{payNo}/callback', [EpayV2Controller::class, 'callback'])->name('epayPayCallback')->setParams(['real_name' => '支付渠道回调']); }); - Route::group('/refunds', function () { - Route::post('/', [RefundController::class, 'create'])->name('refundCreate')->setParams(['real_name' => '创建退款单']); - Route::get('/{refundNo}', [RefundController::class, 'show'])->name('refundDetail')->setParams(['real_name' => '查询退款单']); - Route::post('/{refundNo}/processing', [RefundController::class, 'processing'])->name('refundProcessing')->setParams(['real_name' => '退款处理中']); - Route::post('/{refundNo}/retry', [RefundController::class, 'retry'])->name('refundRetry')->setParams(['real_name' => '退款重试']); - Route::post('/{refundNo}/fail', [RefundController::class, 'markFail'])->name('refundFail')->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('/settlements', function () { - Route::post('/', [SettlementController::class, 'create'])->name('settlementCreate')->setParams(['real_name' => '创建清结算单']); - Route::get('/{settleNo}', [SettlementController::class, 'show'])->name('settlementDetail')->setParams(['real_name' => '查询清结算单']); - Route::post('/{settleNo}/complete', [SettlementController::class, 'complete'])->name('settlementComplete')->setParams(['real_name' => '清结算成功']); - Route::post('/{settleNo}/fail', [SettlementController::class, 'failSettlement'])->name('settlementFail')->setParams(['real_name' => '清结算失败']); - }); - - Route::group('/routes', function () { - Route::get('/resolve', [RouteController::class, 'resolve'])->name('routeResolve')->setParams(['real_name' => '解析路由']); - }); - - Route::group('/traces', function () { - Route::get('/{traceNo}', [TraceController::class, 'show'])->name('traceDetail')->setParams(['real_name' => '追踪查询']); - }); - - Route::group('/notify', function () { - Route::post('/channel', [NotifyController::class, 'channel'])->name('notifyChannel')->setParams(['real_name' => '渠道通知']); - Route::post('/merchant', [NotifyController::class, 'merchant'])->name('notifyMerchant')->setParams(['real_name' => '商户通知']); + // 转账模块 + 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 转账查询']); + Route::post('/balance', [EpayV2Controller::class, 'transferBalance'])->name('epayV2TransferBalance')->setParams(['real_name' => 'ePay V2 转账余额']); }); })->middleware([Cors::class]); diff --git a/app/route/mer.php b/app/route/mer.php index d30c5f1..9927107 100644 --- a/app/route/mer.php +++ b/app/route/mer.php @@ -7,6 +7,7 @@ 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\middleware\MerchantAuthMiddleware; Route::any('/mer[/{path:.+}]', function () { @@ -23,6 +24,19 @@ Route::group('/merapi', function () { 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::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 凭证']); diff --git a/app/service/merchant/MerchantCommandService.php b/app/service/merchant/MerchantCommandService.php index 391f7eb..8250ccf 100644 --- a/app/service/merchant/MerchantCommandService.php +++ b/app/service/merchant/MerchantCommandService.php @@ -263,24 +263,28 @@ class MerchantCommandService extends BaseService /** * 生成或重置商户 API 凭证。 * + * 支持分别重置 V1 API Key 和 V2 RSA 密钥对。 + * * @param int $merchantId 商户ID + * @param array $options 生成选项 * @return array 凭证数据 * @throws ResourceNotFoundException */ - public function issueCredential(int $merchantId): array + public function issueCredential(int $merchantId, array $options = []): array { $merchant = $this->merchantQueryService->findById($merchantId); if (!$merchant) { throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); } - $credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId); - $credential = $this->merchantApiCredentialService->findByMerchantId($merchantId); + $result = $this->merchantApiCredentialService->issueCredentialBundle($merchantId, $options); return [ 'merchant' => $merchant, - 'credential_value' => $credentialValue, - 'credential' => $credential, + 'credential_value' => $result['credential_value'] ?? '', + 'merchant_private_key' => $result['merchant_private_key'] ?? '', + 'credential' => $result['credential'] ?? null, + 'generated' => $result['generated'] ?? [], ]; } @@ -330,6 +334,24 @@ class MerchantCommandService extends BaseService return $merchant; } + /** + * 校验商户是否允许发起支付。 + * + * @param int $merchantId 商户ID + * @return Merchant 商户模型 + * @throws ResourceNotFoundException + * @throws BusinessStateException + */ + public function ensureMerchantPayEnabled(int $merchantId): Merchant + { + $merchant = $this->ensureMerchantEnabled($merchantId); + if ((int) ($merchant->pay_status ?? 1) !== CommonConstant::STATUS_ENABLED) { + throw new BusinessStateException('商户支付已关闭', ['merchant_id' => $merchantId]); + } + + return $merchant; + } + /** * 校验商户分组是否启用。 * diff --git a/app/service/merchant/MerchantService.php b/app/service/merchant/MerchantService.php index df2319f..ea03136 100644 --- a/app/service/merchant/MerchantService.php +++ b/app/service/merchant/MerchantService.php @@ -199,11 +199,12 @@ class MerchantService extends BaseService * 生成或重置商户 API 凭证。 * * @param int $merchantId 商户ID + * @param array $options 生成选项 * @return array 凭证数据 */ - public function issueCredential(int $merchantId): array + public function issueCredential(int $merchantId, array $options = []): array { - return $this->commandService->issueCredential($merchantId); + return $this->commandService->issueCredential($merchantId, $options); } /** @@ -239,6 +240,17 @@ class MerchantService extends BaseService return $this->commandService->ensureMerchantEnabled($merchantId); } + /** + * 校验商户是否允许发起支付。 + * + * @param int $merchantId 商户ID + * @return Merchant 商户模型 + */ + public function ensureMerchantPayEnabled(int $merchantId): Merchant + { + return $this->commandService->ensureMerchantPayEnabled($merchantId); + } + /** * 校验商户分组是否启用。 * @@ -261,5 +273,3 @@ class MerchantService extends BaseService return $this->queryService->findPolicy($merchantId); } } - - diff --git a/app/service/merchant/auth/MerchantAuthService.php b/app/service/merchant/auth/MerchantAuthService.php index d3c8904..d7f04dd 100644 --- a/app/service/merchant/auth/MerchantAuthService.php +++ b/app/service/merchant/auth/MerchantAuthService.php @@ -3,6 +3,7 @@ namespace app\service\merchant\auth; use app\common\base\BaseService; +use app\common\constant\AuthConstant; use app\common\constant\CommonConstant; use app\common\util\JwtTokenManager; use app\exception\ValidationException; @@ -50,7 +51,7 @@ class MerchantAuthService extends BaseService $merchant = $this->merchantPortalSupportService->merchantSummary($merchantId); $credential = $merchantId > 0 ? $this->merchantApiCredentialRepository->findByMerchantId($merchantId) : null; - $isCredentialEnabled = (int) ($credential->status ?? 0) === 1; + $isCredentialEnabled = (int) ($credential->status ?? 0) === AuthConstant::CREDENTIAL_STATUS_ENABLED; $user = [ 'id' => $merchantId, 'deptId' => (string) ($merchant['merchant_group_id'] ?? 0), @@ -104,7 +105,7 @@ class MerchantAuthService extends BaseService */ public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?array { - $result = $this->jwtTokenManager->verify('merchant', $token, $ip, $userAgent); + $result = $this->jwtTokenManager->verify(AuthConstant::GUARD_MERCHANT, $token, $ip, $userAgent); if ($result === null) { return null; } @@ -172,7 +173,7 @@ class MerchantAuthService extends BaseService */ public function revokeToken(string $token): bool { - return $this->jwtTokenManager->revoke('merchant', $token); + return $this->jwtTokenManager->revoke(AuthConstant::GUARD_MERCHANT, $token); } /** @@ -195,7 +196,7 @@ class MerchantAuthService extends BaseService $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); - $issued = $this->jwtTokenManager->issue('merchant', [ + $issued = $this->jwtTokenManager->issue(AuthConstant::GUARD_MERCHANT, [ 'sub' => (string) $merchantId, 'merchant_id' => (int) $merchant->id, 'merchant_no' => (string) $merchant->merchant_no, diff --git a/app/service/merchant/portal/MerchantPortalChannelCommandService.php b/app/service/merchant/portal/MerchantPortalChannelCommandService.php new file mode 100644 index 0000000..d4a55d0 --- /dev/null +++ b/app/service/merchant/portal/MerchantPortalChannelCommandService.php @@ -0,0 +1,388 @@ +paymentPluginRepository->merchantEnabledList([ + 'code', + 'name', + 'config_schema', + 'pay_types', + ])->map(function (PaymentPlugin $plugin): array { + return $this->pluginOption($plugin); + })->values()->all(); + + return [ + 'plugins' => $plugins, + 'pay_types' => $this->supportService->enabledPayTypeOptions(), + ]; + } + + /** + * 查询商户插件配置列表。 + * + * @param array $filters 筛选条件 + * @param int $merchantId 商户ID + * @param int $page 页码 + * @param int $pageSize 每页条数 + * @return array 列表数据 + */ + public function pluginConfigs(array $filters, int $merchantId, int $page, int $pageSize): array + { + $query = $this->paymentPluginConfRepository->query() + ->from('ma_payment_plugin_conf as c') + ->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code') + ->select([ + 'c.id', + 'c.merchant_id', + 'c.plugin_code', + 'c.config', + 'c.settlement_cycle_type', + 'c.settlement_cutoff_time', + 'c.remark', + 'c.created_at', + 'c.updated_at', + ]) + ->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name") + ->where('c.merchant_id', $merchantId); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('c.plugin_code', 'like', '%' . $keyword . '%') + ->orWhere('p.name', 'like', '%' . $keyword . '%') + ->orWhere('c.remark', 'like', '%' . $keyword . '%'); + }); + } + + $pluginCode = trim((string) ($filters['plugin_code'] ?? '')); + if ($pluginCode !== '') { + $query->where('c.plugin_code', $pluginCode); + } + + $paginator = $query + ->orderByDesc('c.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + $row->settlement_cycle_type_text = $this->textFromMap((int) $row->settlement_cycle_type, [ + 0 => 'D0', + 1 => 'D1', + 2 => 'D7', + 3 => 'T1', + 4 => 'OTHER', + ]); + $row->created_at_text = $this->formatDateTime($row->created_at ?? null); + $row->updated_at_text = $this->formatDateTime($row->updated_at ?? null); + + return $row; + }); + + return [ + 'merchant' => $this->supportService->merchantSummary($merchantId), + 'plugins' => $this->createMeta()['plugins'], + 'list' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + ]; + } + + /** + * 新增商户插件配置。 + * + * @param int $merchantId 商户ID + * @param array $data 写入数据 + * @return PaymentPluginConf 配置 + */ + public function createPluginConfig(int $merchantId, array $data): PaymentPluginConf + { + $payload = $this->normalizePluginConfigPayload($merchantId, $data); + $this->assertMerchantPluginAllowed((string) $payload['plugin_code']); + + return $this->paymentPluginConfRepository->create($payload); + } + + /** + * 修改商户插件配置。 + * + * @param int $merchantId 商户ID + * @param int $id 配置ID + * @param array $data 写入数据 + * @return PaymentPluginConf|null 配置 + */ + public function updatePluginConfig(int $merchantId, int $id, array $data): ?PaymentPluginConf + { + $model = $this->paymentPluginConfRepository->findByMerchantAndId($merchantId, $id); + if (!$model) { + return null; + } + + $payload = $this->normalizePluginConfigPayload($merchantId, $data); + $this->assertMerchantPluginAllowed((string) $payload['plugin_code']); + + $model->fill($payload); + $model->save(); + + return $model->refresh(); + } + + /** + * 删除商户插件配置。 + * + * @param int $merchantId 商户ID + * @param int $id 配置ID + * @return bool 是否删除 + */ + public function deletePluginConfig(int $merchantId, int $id): bool + { + $model = $this->paymentPluginConfRepository->findByMerchantAndId($merchantId, $id); + if (!$model) { + return false; + } + + if ($this->paymentChannelRepository->existsBy([ + 'merchant_id' => $merchantId, + 'api_config_id' => $id, + ])) { + throw new PaymentException('该配置已被通道使用,不能删除', 40241); + } + + return (bool) $model->delete(); + } + + /** + * 商户插件配置下拉选项。 + * + * @param int $merchantId 商户ID + * @param string $pluginCode 插件编码 + * @return array 配置选项 + */ + public function pluginConfigOptions(int $merchantId, string $pluginCode = ''): array + { + $query = $this->paymentPluginConfRepository->query() + ->from('ma_payment_plugin_conf as c') + ->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code') + ->select(['c.id', 'c.plugin_code']) + ->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name") + ->where('c.merchant_id', $merchantId) + ->orderByDesc('c.id'); + + $pluginCode = trim($pluginCode); + if ($pluginCode !== '') { + $query->where('c.plugin_code', $pluginCode); + } + + return $query->get()->map(function ($row): array { + return [ + 'label' => sprintf('%s(%d)', (string) $row->plugin_name, (int) $row->id), + 'value' => (int) $row->id, + 'plugin_code' => (string) $row->plugin_code, + 'plugin_name' => (string) $row->plugin_name, + ]; + })->values()->all(); + } + + /** + * 新增商户通道。 + * + * @param int $merchantId 商户ID + * @param array $data 写入数据 + * @return PaymentChannel 通道 + */ + public function createChannel(int $merchantId, array $data): PaymentChannel + { + $payload = $this->normalizeChannelPayload($merchantId, $data); + $this->assertChannelWritable($merchantId, $payload); + $this->assertChannelNameUnique($merchantId, (string) $payload['name']); + + return $this->paymentChannelRepository->create($payload); + } + + /** + * 修改商户通道。 + * + * @param int $merchantId 商户ID + * @param int $id 通道ID + * @param array $data 写入数据 + * @return PaymentChannel|null 通道 + */ + public function updateChannel(int $merchantId, int $id, array $data): ?PaymentChannel + { + $model = $this->paymentChannelRepository->findByMerchantAndId($merchantId, $id); + if (!$model) { + return null; + } + + $payload = $this->normalizeChannelPayload($merchantId, $data); + $this->assertChannelWritable($merchantId, $payload); + $this->assertChannelNameUnique($merchantId, (string) $payload['name'], $id); + + $model->fill($payload); + $model->save(); + + return $model->refresh(); + } + + /** + * 删除商户通道。 + * + * @param int $merchantId 商户ID + * @param int $id 通道ID + * @return bool 是否删除 + */ + public function deleteChannel(int $merchantId, int $id): bool + { + $model = $this->paymentChannelRepository->findByMerchantAndId($merchantId, $id); + if (!$model) { + return false; + } + + return (bool) $model->delete(); + } + + /** + * 根据插件编码查询商户端可用插件结构。 + * + * @param string $pluginCode 插件编码 + * @return array 配置结构 + */ + public function pluginSchema(string $pluginCode): array + { + $plugin = $this->assertMerchantPluginAllowed($pluginCode); + + return [ + 'config_schema' => is_array($plugin->config_schema) ? array_values($plugin->config_schema) : [], + ]; + } + + private function normalizePluginConfigPayload(int $merchantId, array $data): array + { + return [ + 'merchant_id' => $merchantId, + 'plugin_code' => trim((string) ($data['plugin_code'] ?? '')), + 'config' => is_array($data['config'] ?? null) ? $data['config'] : [], + 'settlement_cycle_type' => (int) ($data['settlement_cycle_type'] ?? 1), + 'settlement_cutoff_time' => trim((string) ($data['settlement_cutoff_time'] ?? '23:59:59')) ?: '23:59:59', + 'remark' => trim((string) ($data['remark'] ?? '')), + ]; + } + + private function normalizeChannelPayload(int $merchantId, array $data): array + { + return [ + 'merchant_id' => $merchantId, + 'name' => trim((string) ($data['name'] ?? '')), + 'split_rate_bp' => 10000, + 'cost_rate_bp' => 0, + 'channel_mode' => RouteConstant::CHANNEL_MODE_SELF, + 'pay_type_id' => (int) ($data['pay_type_id'] ?? 0), + 'plugin_code' => trim((string) ($data['plugin_code'] ?? '')), + 'api_config_id' => (int) ($data['api_config_id'] ?? 0), + 'daily_limit_amount' => max(0, (int) ($data['daily_limit_amount'] ?? 0)), + 'daily_limit_count' => max(0, (int) ($data['daily_limit_count'] ?? 0)), + 'min_amount' => max(0, (int) ($data['min_amount'] ?? 0)), + 'max_amount' => max(0, (int) ($data['max_amount'] ?? 0)), + 'remark' => trim((string) ($data['remark'] ?? '')), + 'status' => (int) ($data['status'] ?? CommonConstant::STATUS_ENABLED), + 'sort_no' => max(0, (int) ($data['sort_no'] ?? 0)), + ]; + } + + private function assertChannelWritable(int $merchantId, array $payload): void + { + if ((string) $payload['name'] === '') { + throw new PaymentException('通道名称不能为空', 40242); + } + + $plugin = $this->assertMerchantPluginAllowed((string) $payload['plugin_code']); + $config = $this->paymentPluginConfRepository->findByMerchantAndId($merchantId, (int) $payload['api_config_id']); + if (!$config || (string) $config->plugin_code !== (string) $payload['plugin_code']) { + throw new PaymentException('插件配置不存在或不属于当前插件', 40243); + } + + $payType = $this->paymentTypeRepository->find((int) $payload['pay_type_id']); + if (!$payType) { + throw new PaymentException('支付方式不存在', 40244); + } + + $payTypes = is_array($plugin->pay_types) ? $plugin->pay_types : []; + $payTypeCodes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $payTypes))); + if (!in_array((string) $payType->code, $payTypeCodes, true)) { + throw new PaymentException('支付插件不支持当前支付方式', 40245); + } + + if ((int) $payload['max_amount'] > 0 && (int) $payload['min_amount'] > (int) $payload['max_amount']) { + throw new PaymentException('单笔最小金额不能大于最大金额', 40246); + } + } + + private function assertChannelNameUnique(int $merchantId, string $name, int $ignoreId = 0): void + { + if ($this->paymentChannelRepository->existsByMerchantName($merchantId, $name, $ignoreId)) { + throw new PaymentException('通道名称已存在', 40247); + } + + if ($this->paymentChannelRepository->existsByName($name, $ignoreId)) { + throw new PaymentException('通道名称已被占用,请换一个名称', 40248); + } + } + + private function assertMerchantPluginAllowed(string $pluginCode): PaymentPlugin + { + $plugin = $this->paymentPluginRepository->findMerchantAllowed($pluginCode); + if (!$plugin) { + throw new PaymentException('该支付插件未开放给商户端使用', 40240, [ + 'plugin_code' => $pluginCode, + ]); + } + + return $plugin; + } + + private function pluginOption(PaymentPlugin $plugin): array + { + return [ + 'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code), + 'value' => (string) $plugin->code, + 'code' => (string) $plugin->code, + 'name' => (string) $plugin->name, + 'pay_types' => is_array($plugin->pay_types) ? array_values($plugin->pay_types) : [], + 'config_schema' => is_array($plugin->config_schema) ? array_values($plugin->config_schema) : [], + ]; + } +} diff --git a/app/service/merchant/portal/MerchantPortalChannelService.php b/app/service/merchant/portal/MerchantPortalChannelService.php index 0f6e1cd..5019a66 100644 --- a/app/service/merchant/portal/MerchantPortalChannelService.php +++ b/app/service/merchant/portal/MerchantPortalChannelService.php @@ -8,6 +8,7 @@ use app\common\base\BaseService; * 商户门户通道服务。 * * @property MerchantPortalChannelQueryService $queryService 查询服务 + * @property MerchantPortalChannelCommandService $commandService 命令服务 * @property MerchantPortalRoutePreviewService $routePreviewService 路由解析服务 */ class MerchantPortalChannelService extends BaseService @@ -16,10 +17,12 @@ class MerchantPortalChannelService extends BaseService * 构造方法。 * * @param MerchantPortalChannelQueryService $queryService 查询服务 + * @param MerchantPortalChannelCommandService $commandService 命令服务 * @param MerchantPortalRoutePreviewService $routePreviewService 路由解析服务 */ public function __construct( protected MerchantPortalChannelQueryService $queryService, + protected MerchantPortalChannelCommandService $commandService, protected MerchantPortalRoutePreviewService $routePreviewService ) { } @@ -51,5 +54,54 @@ class MerchantPortalChannelService extends BaseService { return $this->routePreviewService->routePreview($merchantId, $payTypeId, $payAmount, $statDate); } -} + public function createMeta(): array + { + return $this->commandService->createMeta(); + } + + public function pluginConfigs(array $filters, int $merchantId, int $page, int $pageSize): array + { + return $this->commandService->pluginConfigs($filters, $merchantId, $page, $pageSize); + } + + public function createPluginConfig(int $merchantId, array $data) + { + return $this->commandService->createPluginConfig($merchantId, $data); + } + + public function updatePluginConfig(int $merchantId, int $id, array $data) + { + return $this->commandService->updatePluginConfig($merchantId, $id, $data); + } + + public function deletePluginConfig(int $merchantId, int $id): bool + { + return $this->commandService->deletePluginConfig($merchantId, $id); + } + + public function pluginConfigOptions(int $merchantId, string $pluginCode = ''): array + { + return $this->commandService->pluginConfigOptions($merchantId, $pluginCode); + } + + public function createChannel(int $merchantId, array $data) + { + return $this->commandService->createChannel($merchantId, $data); + } + + public function updateChannel(int $merchantId, int $id, array $data) + { + return $this->commandService->updateChannel($merchantId, $id, $data); + } + + public function deleteChannel(int $merchantId, int $id): bool + { + return $this->commandService->deleteChannel($merchantId, $id); + } + + public function pluginSchema(string $pluginCode): array + { + return $this->commandService->pluginSchema($pluginCode); + } +} diff --git a/app/service/merchant/portal/MerchantPortalCredentialCommandService.php b/app/service/merchant/portal/MerchantPortalCredentialCommandService.php index 4ffeff7..6f74582 100644 --- a/app/service/merchant/portal/MerchantPortalCredentialCommandService.php +++ b/app/service/merchant/portal/MerchantPortalCredentialCommandService.php @@ -33,22 +33,44 @@ class MerchantPortalCredentialCommandService extends BaseService * 生成或重置商户 API 凭证。 * * @param int $merchantId 商户ID + * @param array $options 生成选项 * @return array 凭证数据 */ - public function issueCredential(int $merchantId): array + public function issueCredential(int $merchantId, array $options = []): array { $merchant = $this->supportService->merchantSummary($merchantId); - $credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId); + $result = $this->merchantApiCredentialService->issueCredentialBundle($merchantId, $options); + $credentialValue = (string) ($result['credential_value'] ?? ''); + $merchantPrivateKey = (string) ($result['merchant_private_key'] ?? ''); + $generated = (array) ($result['generated'] ?? []); // 凭证明文只在发放当次返回一次,随后再查库只拿脱敏后的展示结构。 $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); return [ - 'merchant' => $merchant, + 'merchant' => $this->formatMerchant($merchant), + 'integration' => $this->supportService->apiIntegrationInfo($merchant), 'credential_value' => $credentialValue, + 'merchant_private_key' => $merchantPrivateKey, + 'generated' => $generated, 'credential' => $credential ? $this->formatCredential($credential, $merchant) : null, ]; } + /** + * 格式化页面所需商户摘要。 + * + * @param array $merchant 商户摘要 + * @return array + */ + private function formatMerchant(array $merchant): array + { + return [ + 'id' => (int) ($merchant['id'] ?? $merchant['merchant_id'] ?? 0), + 'merchant_no' => (string) ($merchant['merchant_no'] ?? ''), + 'merchant_name' => (string) ($merchant['merchant_name'] ?? ''), + ]; + } + /** * 格式化接口凭证展示数据。 * @@ -58,22 +80,25 @@ class MerchantPortalCredentialCommandService extends BaseService */ private function formatCredential(\app\model\merchant\MerchantApiCredential $credential, array $merchant): array { - $signType = (int) $credential->sign_type; + $apiKey = trim((string) $credential->api_key); + $merchantPublicKey = trim((string) ($credential->merchant_public_key ?? '')); + $platformPublicKey = trim((string) config('epay.v2.platform_public_key', '')); return [ 'id' => (int) $credential->id, 'merchant_id' => (int) $credential->merchant_id, - 'merchant_no' => (string) ($merchant['merchant_no'] ?? ''), - 'merchant_name' => (string) ($merchant['merchant_name'] ?? ''), - 'sign_type' => $signType, - 'sign_type_text' => $this->supportService->signTypeText($signType), - // 展示页只保留脱敏后的 key 片段,避免明文凭证再次暴露。 - 'api_key_preview' => $this->maskCredentialValue((string) $credential->api_key), + 'api_key_preview' => $this->maskCredentialValue($apiKey), + 'api_key_full' => $apiKey, + 'merchant_public_key_full' => $merchantPublicKey, + 'merchant_public_key_preview' => $this->maskCredentialValue($merchantPublicKey), + 'platform_public_key_full' => $platformPublicKey, + 'platform_public_key_preview' => $this->maskCredentialValue($platformPublicKey), + 'supports_v1' => $apiKey !== '', + 'supports_v2' => $merchantPublicKey !== '' && $platformPublicKey !== '', + 'v1_status_text' => $apiKey !== '' ? '已配置' : '未配置', + 'v2_status_text' => $merchantPublicKey !== '' && $platformPublicKey !== '' ? '已配置' : '未配置', 'status' => (int) $credential->status, 'status_text' => (string) ($credential->status ? '启用' : '禁用'), - 'last_used_at' => $this->formatDateTime($credential->last_used_at ?? null), - 'created_at' => $this->formatDateTime($credential->created_at ?? null), - 'updated_at' => $this->formatDateTime($credential->updated_at ?? null), ]; } } diff --git a/app/service/merchant/portal/MerchantPortalCredentialQueryService.php b/app/service/merchant/portal/MerchantPortalCredentialQueryService.php index a58cf60..ee308e4 100644 --- a/app/service/merchant/portal/MerchantPortalCredentialQueryService.php +++ b/app/service/merchant/portal/MerchantPortalCredentialQueryService.php @@ -3,7 +3,7 @@ namespace app\service\merchant\portal; use app\common\base\BaseService; -use app\common\constant\CommonConstant; +use app\common\constant\AuthConstant; use app\model\merchant\MerchantApiCredential; use app\repository\merchant\credential\MerchantApiCredentialRepository; @@ -39,12 +39,28 @@ class MerchantPortalCredentialQueryService extends BaseService $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); return [ - 'merchant' => $merchant, + 'merchant' => $this->formatMerchant($merchant), 'has_credential' => $credential !== null, + 'integration' => $this->supportService->apiIntegrationInfo($merchant), 'credential' => $credential ? $this->formatCredential($credential, $merchant) : null, ]; } + /** + * 格式化页面所需商户摘要。 + * + * @param array $merchant 商户摘要 + * @return array + */ + private function formatMerchant(array $merchant): array + { + return [ + 'id' => (int) ($merchant['id'] ?? $merchant['merchant_id'] ?? 0), + 'merchant_no' => (string) ($merchant['merchant_no'] ?? ''), + 'merchant_name' => (string) ($merchant['merchant_name'] ?? ''), + ]; + } + /** * 格式化接口凭证展示数据。 * @@ -54,24 +70,26 @@ class MerchantPortalCredentialQueryService extends BaseService */ private function formatCredential(MerchantApiCredential $credential, array $merchant): array { - $signType = (int) $credential->sign_type; $status = (int) $credential->status; + $apiKey = trim((string) $credential->api_key); + $merchantPublicKey = trim((string) ($credential->merchant_public_key ?? '')); + $platformPublicKey = trim((string) config('epay.v2.platform_public_key', '')); return [ 'id' => (int) $credential->id, 'merchant_id' => (int) $credential->merchant_id, - 'merchant_no' => (string) ($merchant['merchant_no'] ?? ''), - 'merchant_name' => (string) ($merchant['merchant_name'] ?? ''), - 'sign_type' => $signType, - 'sign_type_text' => $this->supportService->signTypeText($signType), - 'api_key_preview' => $this->maskCredentialValue((string) $credential->api_key), + 'api_key_preview' => $this->maskCredentialValue($apiKey), + 'api_key_full' => $apiKey, + 'merchant_public_key_full' => $merchantPublicKey, + 'merchant_public_key_preview' => $this->maskCredentialValue($merchantPublicKey), + 'platform_public_key_full' => $platformPublicKey, + 'platform_public_key_preview' => $this->maskCredentialValue($platformPublicKey), + 'supports_v1' => $apiKey !== '', + 'supports_v2' => $merchantPublicKey !== '' && $platformPublicKey !== '', + 'v1_status_text' => $apiKey !== '' ? '已配置' : '未配置', + 'v2_status_text' => $merchantPublicKey !== '' && $platformPublicKey !== '' ? '已配置' : '未配置', 'status' => $status, - 'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'), - 'last_used_at' => $this->formatDateTime($credential->last_used_at ?? null), - 'created_at' => $this->formatDateTime($credential->created_at ?? null), - 'updated_at' => $this->formatDateTime($credential->updated_at ?? null), + 'status_text' => $this->textFromMap($status, AuthConstant::credentialStatusMap()), ]; } } - - diff --git a/app/service/merchant/portal/MerchantPortalCredentialService.php b/app/service/merchant/portal/MerchantPortalCredentialService.php index 1c96877..4b56d7f 100644 --- a/app/service/merchant/portal/MerchantPortalCredentialService.php +++ b/app/service/merchant/portal/MerchantPortalCredentialService.php @@ -39,11 +39,11 @@ class MerchantPortalCredentialService extends BaseService * 生成或重置商户 API 凭证。 * * @param int $merchantId 商户ID + * @param array $options 生成选项 * @return array 凭证数据 */ - public function issueCredential(int $merchantId): array + public function issueCredential(int $merchantId, array $options = []): array { - return $this->commandService->issueCredential($merchantId); + return $this->commandService->issueCredential($merchantId, $options); } } - diff --git a/app/service/merchant/portal/MerchantPortalRoutePreviewService.php b/app/service/merchant/portal/MerchantPortalRoutePreviewService.php index 9d0b1b9..330a7b1 100644 --- a/app/service/merchant/portal/MerchantPortalRoutePreviewService.php +++ b/app/service/merchant/portal/MerchantPortalRoutePreviewService.php @@ -95,7 +95,7 @@ class MerchantPortalRoutePreviewService extends BaseService )); } catch (Throwable $e) { // 解析异常只影响路由结果,不影响基础信息展示,因此这里只回填失败原因。 - $response['reason'] = $e->getMessage() !== '' ? $e->getMessage() : '路由解析失败'; + $response['reason'] = $e->getMessage(); } return $response; diff --git a/app/service/merchant/portal/MerchantPortalService.php b/app/service/merchant/portal/MerchantPortalService.php index 731d660..5a8a49d 100644 --- a/app/service/merchant/portal/MerchantPortalService.php +++ b/app/service/merchant/portal/MerchantPortalService.php @@ -79,6 +79,56 @@ class MerchantPortalService extends BaseService return $this->channelService->myChannels($filters, $merchantId, $page, $pageSize); } + public function channelCreateMeta(): array + { + return $this->channelService->createMeta(); + } + + public function pluginConfigs(array $filters, int $merchantId, int $page, int $pageSize): array + { + return $this->channelService->pluginConfigs($filters, $merchantId, $page, $pageSize); + } + + public function createPluginConfig(int $merchantId, array $data) + { + return $this->channelService->createPluginConfig($merchantId, $data); + } + + public function updatePluginConfig(int $merchantId, int $id, array $data) + { + return $this->channelService->updatePluginConfig($merchantId, $id, $data); + } + + public function deletePluginConfig(int $merchantId, int $id): bool + { + return $this->channelService->deletePluginConfig($merchantId, $id); + } + + public function pluginConfigOptions(int $merchantId, string $pluginCode = ''): array + { + return $this->channelService->pluginConfigOptions($merchantId, $pluginCode); + } + + public function createChannel(int $merchantId, array $data) + { + return $this->channelService->createChannel($merchantId, $data); + } + + public function updateChannel(int $merchantId, int $id, array $data) + { + return $this->channelService->updateChannel($merchantId, $id, $data); + } + + public function deleteChannel(int $merchantId, int $id): bool + { + return $this->channelService->deleteChannel($merchantId, $id); + } + + public function pluginSchema(string $pluginCode): array + { + return $this->channelService->pluginSchema($pluginCode); + } + /** * 获取商户路由解析结果。 * @@ -108,11 +158,12 @@ class MerchantPortalService extends BaseService * 生成或重置商户门户接口凭证。 * * @param int $merchantId 商户ID + * @param array $options 生成选项 * @return array 凭证数据 */ - public function issueCredential(int $merchantId): array + public function issueCredential(int $merchantId, array $options = []): array { - return $this->credentialService->issueCredential($merchantId); + return $this->credentialService->issueCredential($merchantId, $options); } /** diff --git a/app/service/merchant/portal/MerchantPortalSupportService.php b/app/service/merchant/portal/MerchantPortalSupportService.php index f7cc762..3029d58 100644 --- a/app/service/merchant/portal/MerchantPortalSupportService.php +++ b/app/service/merchant/portal/MerchantPortalSupportService.php @@ -127,7 +127,31 @@ class MerchantPortalSupportService extends BaseService */ public function enabledPayTypeOptions(): array { - return $this->paymentTypeService->enabledOptions(); + return array_values(array_filter( + $this->paymentTypeService->enabledOptions(), + static function (array $option): bool { + $label = trim((string) ($option['label'] ?? '')); + $value = trim((string) ($option['value'] ?? '')); + + return $label !== '' && $label !== $value; + } + )); + } + + /** + * 商户开放接口对接信息。 + * + * @param array $merchant 商户摘要 + * @return array 对接信息 + */ + public function apiIntegrationInfo(array $merchant): array + { + $baseUrl = rtrim((string) sys_config('site_url', ''), '/'); + + return [ + 'base_url' => $baseUrl, + 'merchant_id' => (int) ($merchant['merchant_id'] ?? $merchant['id'] ?? 0), + ]; } /** diff --git a/app/service/merchant/security/MerchantApiCredentialQueryService.php b/app/service/merchant/security/MerchantApiCredentialQueryService.php index 8bd6723..3ef45d3 100644 --- a/app/service/merchant/security/MerchantApiCredentialQueryService.php +++ b/app/service/merchant/security/MerchantApiCredentialQueryService.php @@ -63,7 +63,12 @@ class MerchantApiCredentialQueryService extends BaseService $paginator->getCollection()->transform(function ($row) { $row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap()); - $row->status_text = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用'; + $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; }); @@ -110,6 +115,7 @@ class MerchantApiCredentialQueryService extends BaseService 'c.id', 'c.merchant_id', 'c.sign_type', + 'c.merchant_public_key', 'c.status', 'c.last_used_at', 'c.created_at', @@ -120,9 +126,12 @@ class MerchantApiCredentialQueryService extends BaseService if ($maskCredentialValue) { $query->selectRaw("CASE WHEN c.api_key IS NULL OR c.api_key = '' THEN '' ELSE CONCAT(LEFT(c.api_key, 4), '****', RIGHT(c.api_key, 4)) END AS api_key_preview"); + $query->selectRaw("CASE WHEN c.merchant_public_key IS NULL OR c.merchant_public_key = '' THEN '' ELSE CONCAT(LEFT(c.merchant_public_key, 12), '****', RIGHT(c.merchant_public_key, 12)) END AS merchant_public_key_preview"); } else { $query->addSelect('c.api_key'); + $query->addSelect('c.merchant_public_key'); $query->selectRaw("COALESCE(c.api_key, '') AS api_key_full"); + $query->selectRaw("COALESCE(c.merchant_public_key, '') AS merchant_public_key_full"); } return $query; @@ -141,8 +150,12 @@ 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 = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用'; + $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 9d4229a..c2e6ea4 100644 --- a/app/service/merchant/security/MerchantApiCredentialService.php +++ b/app/service/merchant/security/MerchantApiCredentialService.php @@ -5,6 +5,7 @@ namespace app\service\merchant\security; use app\common\base\BaseService; use app\common\constant\AuthConstant; use app\common\constant\CommonConstant; +use app\common\util\RsaKeyPairGenerator; use app\exception\ResourceNotFoundException; use app\exception\ValidationException; use app\model\merchant\Merchant; @@ -13,9 +14,9 @@ use app\repository\merchant\credential\MerchantApiCredentialRepository; use app\repository\merchant\base\MerchantRepository; /** - * 商户对外接口凭证与签名校验服务。 + * 商户对外接口凭证服务。 * - * 负责商户外部接口签名校验、接口凭证发放和最近使用时间更新。 + * 负责接口凭证发放、查询和最近使用时间更新。 * * @property MerchantRepository $merchantRepository 商户仓库 * @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库 @@ -51,82 +52,7 @@ class MerchantApiCredentialService extends BaseService } /** - * 校验外部支付接口的 MD5 签名。 - * - * 会先校验商户和接口凭证是否存在,再按签名规则计算并比对请求签名。 - * - * @param array $payload 请求载荷 - * @return array{merchant: Merchant, credential: MerchantApiCredential} 校验通过后的商户和凭证数据 - * @throws ValidationException - * @throws ResourceNotFoundException - */ - public function verifyMd5Sign(array $payload): array - { - $merchantId = (int) ($payload['pid'] ?? $payload['merchant_id'] ?? 0); - $sign = trim((string) ($payload['sign'] ?? '')); - $signType = strtoupper((string) ($payload['sign_type'] ?? 'MD5')); - $providedKey = trim((string) ($payload['key'] ?? '')); - - if ($merchantId <= 0 || $sign === '') { - throw new ValidationException('pid/sign 参数缺失'); - } - - if ($signType !== 'MD5') { - throw new ValidationException('仅支持 MD5 签名'); - } - - /** @var Merchant|null $merchant */ - $merchant = $this->merchantRepository->find($merchantId); - if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) { - throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); - } - - /** @var MerchantApiCredential|null $credential */ - $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); - if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) { - throw new ValidationException('商户 API 凭证未开通'); - } - - if ($providedKey !== '' && !hash_equals((string) $credential->api_key, $providedKey)) { - throw new ValidationException('商户 API 凭证错误'); - } - - // 签名字段本身不参与原文拼接,只保留业务参数。 - $params = $payload; - unset($params['sign'], $params['sign_type'], $params['key']); - // 过滤空值并按键名排序,保证不同参数顺序下得到同一签名串。 - foreach ($params as $paramKey => $paramValue) { - if ($paramValue === '' || $paramValue === null) { - unset($params[$paramKey]); - } - } - ksort($params); - - $key = (string) $credential->api_key; - $query = []; - // 旧版 ePay 采用 `a=1&b=2` 再拼接 key 的方式验签,这里保持兼容。 - foreach ($params as $paramKey => $paramValue) { - $query[] = $paramKey . '=' . $paramValue; - } - $base = implode('&', $query) . $key; - $expected = md5($base); - - // 使用常量时间比较,避免签名对比被时序差异放大。 - if (!hash_equals(strtolower($expected), strtolower($sign))) { - throw new ValidationException('签名验证失败'); - } - - $credential->last_used_at = $this->now(); - $credential->save(); - - return [ - 'merchant' => $merchant, - 'credential' => $credential, - ]; - } - - /** - * 为商户生成并保存一份新的接口凭证。 + * 为商户生成并保存一份新的 V1 接口凭证。 * * 返回值是明文接口凭证值,只会在调用时完整出现一次,后续仅保存脱敏展示。 * @@ -135,24 +61,77 @@ class MerchantApiCredentialService extends BaseService * @throws ResourceNotFoundException */ public function issueCredential(int $merchantId): string + { + $result = $this->issueCredentialBundle($merchantId, [ + 'rotate_v1' => true, + 'rotate_v2' => false, + ]); + + return (string) ($result['credential_value'] ?? ''); + } + + /** + * 为商户生成一组接口凭证。 + * + * 该方法可同时重置 V1 API Key 和 V2 RSA 密钥对,适合管理后台的自动生成场景。 + * 生成后的私钥只在返回结果里出现一次,不会落库。 + * + * @param int $merchantId 商户ID + * @param array $options 生成选项 + * @return array 凭证数据和生成结果 + * @throws ResourceNotFoundException + * @throws ValidationException + */ + public function issueCredentialBundle(int $merchantId, array $options = []): array { $merchant = $this->merchantRepository->find($merchantId); if (!$merchant) { throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]); } - $credentialValue = $this->generateCredentialValue(); - $this->merchantApiCredentialRepository->updateOrCreate( + $current = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + $rotateV1 = array_key_exists('rotate_v1', $options) ? (bool) $options['rotate_v1'] : true; + $rotateV2 = array_key_exists('rotate_v2', $options) ? (bool) $options['rotate_v2'] : true; + if (!$rotateV1 && !$rotateV2) { + 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 = ''; + $merchantPublicKey = trim((string) ($current?->merchant_public_key ?? '')); + + if ($rotateV2) { + $pair = RsaKeyPairGenerator::generate(); + $merchantPrivateKey = $pair['private_key']; + $merchantPublicKey = $pair['public_key']; + } + + $credential = $this->merchantApiCredentialRepository->updateOrCreate( ['merchant_id' => $merchantId], [ 'merchant_id' => $merchantId, - 'sign_type' => AuthConstant::API_SIGN_TYPE_MD5, + 'sign_type' => $signType, + 'status' => $status, 'api_key' => $credentialValue, - 'status' => AuthConstant::LOGIN_STATUS_ENABLED, + 'merchant_public_key' => $merchantPublicKey, ] ); - return $credentialValue; + return [ + 'merchant' => $merchant, + 'credential' => $credential, + 'credential_value' => $credentialValue, + 'merchant_private_key' => $merchantPrivateKey, + 'generated' => [ + 'rotate_v1' => $rotateV1, + 'rotate_v2' => $rotateV2, + 'api_key' => $rotateV1 ? $credentialValue : '', + 'merchant_private_key' => $merchantPrivateKey, + 'merchant_public_key' => $merchantPublicKey, + ], + ]; } /** @@ -203,6 +182,12 @@ class MerchantApiCredentialService extends BaseService } } + $apiKey = trim((string) ($data['api_key'] ?? '')); + $merchantPublicKey = trim((string) ($data['merchant_public_key'] ?? '')); + if ($apiKey === '' && $merchantPublicKey === '') { + throw new ValidationException('请至少填写 V1 API Key 或 V2 商户 RSA 公钥'); + } + return $this->merchantApiCredentialRepository->create($this->normalizePayload($data, false)); } @@ -264,7 +249,7 @@ class MerchantApiCredentialService extends BaseService /** @var MerchantApiCredential|null $credential */ $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); - if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) { + if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) { throw new ValidationException('商户 API 凭证未开通'); } @@ -288,24 +273,33 @@ class MerchantApiCredentialService extends BaseService * @param array $data 凭证数据 * @param bool $isUpdate 是否更新 * @param MerchantApiCredential|null $current 当前凭证 + * 更新场景下,空字符串视为“不修改”,避免手动配置时误清空已有密钥。 + * `sign_type` 在当前阶段只作为展示/默认接入说明,不再作为 V1/V2 互斥开关。 + * * @return array{merchant_id: int, sign_type: 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'] ?? AuthConstant::API_SIGN_TYPE_MD5), - 'status' => (int) ($data['status'] ?? AuthConstant::LOGIN_STATUS_ENABLED), + 'sign_type' => (int) ($data['sign_type'] ?? $currentSignType), + 'status' => (int) ($data['status'] ?? $currentStatus), ]; $apiKey = trim((string) ($data['api_key'] ?? '')); if ($apiKey !== '') { $payload['api_key'] = $apiKey; - } elseif (!$isUpdate) { - // 新增凭证时如果前端没有传入明文 key,就自动补一份随机值。 - $payload['api_key'] = $this->generateCredentialValue(); + } + + if (array_key_exists('merchant_public_key', $data)) { + $merchantPublicKey = trim((string) ($data['merchant_public_key'] ?? '')); + if ($merchantPublicKey !== '' || !$isUpdate) { + $payload['merchant_public_key'] = $merchantPublicKey; + } } return $payload; diff --git a/app/service/ops/log/ChannelNotifyLogService.php b/app/service/ops/log/ChannelNotifyLogService.php index d6ad580..3ff6555 100644 --- a/app/service/ops/log/ChannelNotifyLogService.php +++ b/app/service/ops/log/ChannelNotifyLogService.php @@ -48,8 +48,8 @@ class ChannelNotifyLogService extends BaseService ->orWhere('n.channel_request_no', 'like', '%' . $keyword . '%') ->orWhere('n.channel_trade_no', 'like', '%' . $keyword . '%') ->orWhere('n.last_error', 'like', '%' . $keyword . '%') - ->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%') - ->orWhere('p.subject', 'like', '%' . $keyword . '%') + ->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.subject', 'like', '%' . $keyword . '%') ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') @@ -107,7 +107,7 @@ class ChannelNotifyLogService extends BaseService ->where('n.id', $id) ->first(); - return $row ?: null; + return $row ? $this->decorateRow($row) : null; } /** @@ -139,6 +139,7 @@ class ChannelNotifyLogService extends BaseService return $this->channelNotifyLogRepository->query() ->from('ma_channel_notify_log as n') ->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'n.pay_no') + ->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'n.biz_no') ->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id') ->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id') ->leftJoin('ma_payment_channel as c', 'c.id', '=', 'n.channel_id') @@ -160,8 +161,8 @@ class ChannelNotifyLogService extends BaseService 'n.created_at', 'n.updated_at', 'p.merchant_id', - 'p.merchant_order_no', - 'p.subject', + 'bo.merchant_order_no', + 'bo.subject', ]) ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") @@ -173,5 +174,3 @@ class ChannelNotifyLogService extends BaseService } - - diff --git a/app/service/ops/log/MerchantNotifyTaskService.php b/app/service/ops/log/MerchantNotifyTaskService.php new file mode 100644 index 0000000..20cfe08 --- /dev/null +++ b/app/service/ops/log/MerchantNotifyTaskService.php @@ -0,0 +1,161 @@ +baseQuery(); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->where('n.notify_no', 'like', '%' . $keyword . '%') + ->orWhere('n.event_type', 'like', '%' . $keyword . '%') + ->orWhere('n.ref_no', 'like', '%' . $keyword . '%') + ->orWhere('n.biz_no', 'like', '%' . $keyword . '%') + ->orWhere('n.pay_no', 'like', '%' . $keyword . '%') + ->orWhere('n.notify_url', 'like', '%' . $keyword . '%') + ->orWhere('n.last_response', 'like', '%' . $keyword . '%') + ->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.subject', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%'); + }); + } + + $merchantId = (string) ($filters['merchant_id'] ?? ''); + if ($merchantId !== '') { + $query->where('n.merchant_id', (int) $merchantId); + } + + $status = (string) ($filters['status'] ?? ''); + if ($status !== '') { + $query->where('n.status', (int) $status); + } + + $paginator = $query + ->orderByDesc('n.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $paginator->getCollection()->transform(function ($row) { + return $this->decorateRow($row); + }); + + return $paginator; + } + + /** + * 查询商户通知任务详情。 + * + * @param string $notifyNo 通知号 + * @return NotifyTask|object|null + */ + public function findByNotifyNo(string $notifyNo): mixed + { + $row = $this->baseQuery() + ->where('n.notify_no', $notifyNo) + ->first(); + + return $row ? $this->decorateRow($row) : null; + } + + /** + * 手动重试通知任务。 + * + * @param string $notifyNo 通知号 + * @return mixed + */ + public function retry(string $notifyNo): mixed + { + $this->merchantNotifyDispatcherService->dispatchTask($notifyNo); + + return $this->findByNotifyNo($notifyNo); + } + + /** + * 格式化单条记录。 + * + * @param object $row 原始查询对象 + * @return object + */ + private function decorateRow(object $row): object + { + $row->event_type_text = (string) (NotifyConstant::eventTypeMap()[(string) $row->event_type] ?? (string) $row->event_type); + $row->status_text = (string) (NotifyConstant::taskStatusMap()[(int) $row->status] ?? '未知'); + $row->notify_data_text = $this->formatJson($row->notify_data ?? null); + $row->last_notify_at_text = $this->formatDateTime($row->last_notify_at ?? null); + $row->next_retry_at_text = $this->formatDateTime($row->next_retry_at ?? null); + $row->created_at_text = $this->formatDateTime($row->created_at ?? null); + $row->updated_at_text = $this->formatDateTime($row->updated_at ?? null); + + return $row; + } + + /** + * 构建基础查询。 + * + * @return \Illuminate\Database\Eloquent\Builder + */ + private function baseQuery() + { + return $this->notifyTaskRepository->query() + ->from('ma_notify_task as n') + ->leftJoin('ma_merchant as m', 'm.id', '=', 'n.merchant_id') + ->leftJoin('ma_merchant_group as g', 'g.id', '=', 'n.merchant_group_id') + ->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'n.biz_no') + ->select([ + 'n.id', + 'n.notify_no', + 'n.event_type', + 'n.ref_no', + 'n.merchant_id', + 'n.merchant_group_id', + 'n.biz_no', + 'n.pay_no', + 'n.notify_url', + 'n.notify_data', + 'n.status', + 'n.retry_count', + 'n.next_retry_at', + 'n.last_notify_at', + 'n.last_response', + 'n.created_at', + 'n.updated_at', + 'bo.merchant_order_no', + 'bo.subject', + ]) + ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") + ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") + ->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name") + ->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name"); + } +} diff --git a/app/service/ops/log/PayCallbackLogService.php b/app/service/ops/log/PayCallbackLogService.php index c32ced5..61bc6e5 100644 --- a/app/service/ops/log/PayCallbackLogService.php +++ b/app/service/ops/log/PayCallbackLogService.php @@ -43,8 +43,8 @@ class PayCallbackLogService extends BaseService if ($keyword !== '') { $query->where(function ($builder) use ($keyword) { $builder->where('l.pay_no', 'like', '%' . $keyword . '%') - ->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%') - ->orWhere('p.subject', 'like', '%' . $keyword . '%') + ->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.subject', 'like', '%' . $keyword . '%') ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') @@ -102,7 +102,7 @@ class PayCallbackLogService extends BaseService ->where('l.id', $id) ->first(); - return $row ?: null; + return $row ? $this->decorateRow($row) : null; } /** @@ -133,6 +133,7 @@ class PayCallbackLogService extends BaseService return $this->payCallbackLogRepository->query() ->from('ma_pay_callback_log as l') ->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'l.pay_no') + ->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'p.biz_no') ->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id') ->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id') ->leftJoin('ma_payment_channel as c', 'c.id', '=', 'l.channel_id') @@ -142,13 +143,14 @@ class PayCallbackLogService extends BaseService 'l.channel_id', 'l.callback_type', 'l.request_data', + 'l.request_hash', 'l.verify_status', 'l.process_status', 'l.process_result', 'l.created_at', 'p.merchant_id', - 'p.merchant_order_no', - 'p.subject', + 'bo.merchant_order_no', + 'bo.subject', ]) ->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no") ->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name") @@ -160,5 +162,3 @@ class PayCallbackLogService extends BaseService } - - diff --git a/app/service/payment/cashier/CashierService.php b/app/service/payment/cashier/CashierService.php new file mode 100644 index 0000000..5a886e5 --- /dev/null +++ b/app/service/payment/cashier/CashierService.php @@ -0,0 +1,293 @@ + + */ + public function context(string $bizNo): array + { + $bizNo = trim($bizNo); + if ($bizNo === '') { + throw new ValidationException('biz_no 不能为空'); + } + + $bizOrder = $this->bizOrderRepository->findByBizNo($bizNo); + if (!$bizOrder) { + throw new ResourceNotFoundException('业务单不存在', ['biz_no' => $bizNo]); + } + + $merchant = $this->merchantService->ensureMerchantEnabled((int) $bizOrder->merchant_id); + $this->merchantService->ensureMerchantGroupEnabled((int) $merchant->group_id); + + $activePayOrder = $this->resolveActivePayOrder($bizOrder); + $paySwitchEnabled = (int) ($merchant->pay_status ?? CommonConstant::STATUS_ENABLED) === CommonConstant::STATUS_ENABLED; + $canPay = $paySwitchEnabled && !in_array((int) $bizOrder->status, [ + TradeConstant::ORDER_STATUS_SUCCESS, + TradeConstant::ORDER_STATUS_CLOSED, + TradeConstant::ORDER_STATUS_TIMEOUT, + ], true) && (!$activePayOrder || !in_array((int) $activePayOrder->status, [ + TradeConstant::ORDER_STATUS_CREATED, + TradeConstant::ORDER_STATUS_PAYING, + ], true)); + + // 收银台首屏只做“展示 + 可选方式预览”,不在这里创建支付单。 + $availablePayTypes = $canPay + ? $this->paymentRouteService->previewAvailablePayTypes( + (int) $merchant->group_id, + (int) $bizOrder->order_amount, + ['stat_date' => FormatHelper::timestamp(time(), 'Y-m-d')] + ) + : []; + + return [ + 'biz_order' => $this->formatBizOrder($bizOrder), + 'merchant' => $this->formatMerchant($merchant), + 'active_pay_order' => $activePayOrder ? $this->formatActivePayOrder($activePayOrder) : null, + 'available_pay_types' => $availablePayTypes, + 'can_pay' => $canPay, + ]; + } + + /** + * 确认支付方式并创建支付单。 + * + * @param array $input 请求参数 + * @param Request $request 请求对象 + * @return array + */ + public function confirm(array $input, Request $request): array + { + $bizNo = trim((string) ($input['biz_no'] ?? '')); + $typeCode = trim((string) ($input['type'] ?? '')); + if ($bizNo === '') { + throw new ValidationException('biz_no 不能为空'); + } + if ($typeCode === '') { + throw new ValidationException('type 不能为空'); + } + + $bizOrder = $this->bizOrderRepository->findByBizNo($bizNo); + if (!$bizOrder) { + throw new ResourceNotFoundException('业务单不存在', ['biz_no' => $bizNo]); + } + + // 先恢复业务单,再把用户选中的支付方式转成一次明确的支付尝试。 + $merchant = $this->merchantService->ensureMerchantPayEnabled((int) $bizOrder->merchant_id); + $this->merchantService->ensureMerchantGroupEnabled((int) $merchant->group_id); + $activePayOrder = $this->resolveActivePayOrder($bizOrder); + if ($activePayOrder && in_array((int) $activePayOrder->status, [ + TradeConstant::ORDER_STATUS_CREATED, + TradeConstant::ORDER_STATUS_PAYING, + ], true)) { + throw new ValidationException('当前订单已有进行中的支付尝试'); + } + + $paymentType = $this->paymentTypeService->findByCode($typeCode); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new ValidationException('支付方式不支持'); + } + + // 收银台确认阶段只认业务单快照,避免前端再次篡改订单展示字段。 + $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'], + ]); + + /** @var PayOrder $payOrder */ + $payOrder = $attempt['pay_order']; + $payParams = (array) ($attempt['pay_params'] ?? []); + $paymentResult = (array) ($attempt['payment_result'] ?? []); + + 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_info' => $payParams, + 'payment_result' => $paymentResult, + 'payment_page_path' => $this->buildPaymentPagePath((string) $payOrder->pay_no), + 'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no), + ]; + } + + /** + * 查询支付页详情。 + * + * @param string $payNo 支付单号 + * @return array + */ + public function payOrderDetail(string $payNo): array + { + return $this->payOrderService->detail($payNo); + } + + /** + * 解析当前业务单的活跃支付单。 + * + * @param BizOrder $bizOrder 业务单 + * @return PayOrder|null 支付单 + */ + private function resolveActivePayOrder(BizOrder $bizOrder): ?PayOrder + { + $activePayNo = trim((string) ($bizOrder->active_pay_no ?? '')); + if ($activePayNo === '') { + return null; + } + + return $this->payOrderRepository->findByPayNo($activePayNo); + } + + /** + * 格式化业务单。 + * + * @param BizOrder $bizOrder 业务单 + * @return array + */ + private function formatBizOrder(BizOrder $bizOrder): array + { + $statusMap = TradeConstant::orderStatusMap(); + + return [ + 'biz_no' => (string) $bizOrder->biz_no, + 'trace_no' => (string) ($bizOrder->trace_no ?? ''), + 'merchant_order_no' => (string) $bizOrder->merchant_order_no, + '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 ?? ''), + 'order_amount' => (int) $bizOrder->order_amount, + 'order_amount_text' => FormatHelper::amount((int) $bizOrder->order_amount), + 'paid_amount' => (int) $bizOrder->paid_amount, + 'refund_amount' => (int) $bizOrder->refund_amount, + 'status' => (int) $bizOrder->status, + 'status_text' => (string) ($statusMap[(int) $bizOrder->status] ?? ''), + 'active_pay_no' => (string) ($bizOrder->active_pay_no ?? ''), + 'attempt_count' => (int) $bizOrder->attempt_count, + 'ext_json' => (array) ($bizOrder->ext_json ?? []), + 'created_at' => FormatHelper::dateTime($bizOrder->created_at), + 'updated_at' => FormatHelper::dateTime($bizOrder->updated_at), + ]; + } + + /** + * 格式化商户信息。 + * + * @param Merchant $merchant 商户 + * @return array + */ + private function formatMerchant(Merchant $merchant): array + { + return [ + '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 ?? ''), + 'status' => (int) $merchant->status, + 'pay_status' => (int) ($merchant->pay_status ?? 1), + 'settle_status' => (int) ($merchant->settle_status ?? 1), + 'settle_type' => (int) ($merchant->settle_type ?? 4), + ]; + } + + /** + * 格式化活跃支付单。 + * + * @param PayOrder $payOrder 支付单 + * @return array + */ + private function formatActivePayOrder(PayOrder $payOrder): array + { + return [ + 'pay_no' => (string) $payOrder->pay_no, + 'pay_type_code' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id), + '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), + ]; + } + + /** + * 构建支付页路径。 + * + * @param string $payNo 支付单号 + * @return string + */ + private function buildPaymentPagePath(string $payNo): string + { + return '/payment/' . rawurlencode($payNo); + } + + /** + * 构建支付页完整地址。 + * + * @param string $payNo 支付单号 + * @return string + */ + private function buildPaymentPageUrl(string $payNo): string + { + return rtrim((string) sys_config('site_url'), '/') . $this->buildPaymentPagePath($payNo); + } + +} diff --git a/app/service/payment/config/PaymentPluginConfService.php b/app/service/payment/config/PaymentPluginConfService.php index ef1a07c..ad70e0d 100644 --- a/app/service/payment/config/PaymentPluginConfService.php +++ b/app/service/payment/config/PaymentPluginConfService.php @@ -46,6 +46,7 @@ class PaymentPluginConfService extends BaseService ->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code') ->select([ 'c.id', + 'c.merchant_id', 'c.plugin_code', 'c.config', 'c.settlement_cycle_type', @@ -71,6 +72,11 @@ class PaymentPluginConfService extends BaseService $query->where('c.plugin_code', $pluginCode); } + $merchantId = trim((string) ($filters['merchant_id'] ?? '')); + if ($merchantId !== '') { + $query->where('c.merchant_id', (int) $merchantId); + } + return $query ->orderByDesc('c.id') ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); @@ -249,6 +255,7 @@ class PaymentPluginConfService extends BaseService { return [ 'plugin_code' => trim((string) ($data['plugin_code'] ?? '')), + 'merchant_id' => max(0, (int) ($data['merchant_id'] ?? 0)), // 配置内容统一按数组保存,外部传入非数组时直接回退为空数组。 'config' => is_array($data['config'] ?? null) ? $data['config'] : [], // 默认结算周期按日配置,截止时间默认按当天 23:59:59 收口。 diff --git a/app/service/payment/config/PaymentPluginService.php b/app/service/payment/config/PaymentPluginService.php index e831a99..952c878 100644 --- a/app/service/payment/config/PaymentPluginService.php +++ b/app/service/payment/config/PaymentPluginService.php @@ -216,6 +216,10 @@ class PaymentPluginService extends BaseService $payload['status'] = (int) $data['status']; } + if (array_key_exists('allow_merchant', $data)) { + $payload['allow_merchant'] = (int) $data['allow_merchant']; + } + if (array_key_exists('remark', $data)) { $payload['remark'] = trim((string) $data['remark']); } diff --git a/app/service/payment/config/PaymentPluginSyncService.php b/app/service/payment/config/PaymentPluginSyncService.php index 758c91e..cbbe699 100644 --- a/app/service/payment/config/PaymentPluginSyncService.php +++ b/app/service/payment/config/PaymentPluginSyncService.php @@ -90,6 +90,7 @@ class PaymentPluginSyncService extends BaseService $current = $existing[$code] ?? null; $payload = array_merge($row, [ 'status' => (int) ($current->status ?? 1), + 'allow_merchant' => (int) ($current->allow_merchant ?? 0), 'remark' => (string) ($current->remark ?? ''), ]); @@ -141,4 +142,3 @@ class PaymentPluginSyncService extends BaseService } } - diff --git a/app/service/payment/config/PaymentTypeService.php b/app/service/payment/config/PaymentTypeService.php index 8faf0da..f4375bc 100644 --- a/app/service/payment/config/PaymentTypeService.php +++ b/app/service/payment/config/PaymentTypeService.php @@ -3,6 +3,7 @@ namespace app\service\payment\config; use app\common\base\BaseService; +use app\common\constant\CommonConstant; use app\exception\ValidationException; use app\model\payment\PaymentType; use app\repository\payment\config\PaymentTypeRepository; @@ -87,7 +88,9 @@ class PaymentTypeService extends BaseService } /** - * 解析启用中的支付方式,优先按编码匹配,未命中则取首个启用项。 + * 解析启用中的支付方式。 + * + * 仅在显式传入编码且命中启用项时返回,未命中直接抛错,不再提供默认回退。 * * @param string $code 支付方式编码 * @return PaymentType 支付方式模型 @@ -98,18 +101,12 @@ class PaymentTypeService extends BaseService $code = trim($code); if ($code !== '') { $paymentType = $this->paymentTypeRepository->findByCode($code); - if ($paymentType && (int) $paymentType->status === 1) { + if ($paymentType && (int) $paymentType->status === CommonConstant::STATUS_ENABLED) { return $paymentType; } } - // 没有传编码或编码不可用时,直接回退到系统当前首个启用支付方式。 - $paymentType = $this->paymentTypeRepository->enabledList()->first(); - if (!$paymentType) { - throw new ValidationException('未配置可用支付方式'); - } - - return $paymentType; + throw new ValidationException('未配置可用支付方式'); } /** @@ -184,5 +181,3 @@ class PaymentTypeService extends BaseService return $this->paymentTypeRepository->deleteById($id); } } - - diff --git a/app/service/payment/epay/EpaySignerAbstract.php b/app/service/payment/epay/EpaySignerAbstract.php new file mode 100644 index 0000000..cb8d604 --- /dev/null +++ b/app/service/payment/epay/EpaySignerAbstract.php @@ -0,0 +1,72 @@ + $params 待签名参数 + * @return string 签名原文 + */ + protected function buildContent(array $params): string + { + ksort($params); + $parts = []; + + foreach ($params as $key => $value) { + if ($key === 'sign' || $key === 'sign_type') { + continue; + } + + if (is_array($value) || is_object($value)) { + continue; + } + + if ($value === null || $value === '') { + continue; + } + + $parts[] = $key . '=' . (string) $value; + } + + return implode('&', $parts); + } + + /** + * 归一化 PEM 密钥。 + * + * @param string $key 原始密钥 + * @param string $type 密钥类型 + * @return string PEM 格式密钥 + */ + protected function normalizePem(string $key, string $type): string + { + $key = trim($key); + if ($key === '') { + return ''; + } + + if (str_contains($key, 'BEGIN ')) { + return $key; + } + + $type = strtoupper(trim($type)); + $body = preg_replace('/\s+/', '', $key) ?? $key; + + return sprintf( + "-----BEGIN %s KEY-----\n%s\n-----END %s KEY-----", + $type, + trim(chunk_split($body, 64, "\n")), + $type + ); + } +} diff --git a/app/service/payment/epay/EpaySignerInterface.php b/app/service/payment/epay/EpaySignerInterface.php new file mode 100644 index 0000000..c111a42 --- /dev/null +++ b/app/service/payment/epay/EpaySignerInterface.php @@ -0,0 +1,30 @@ + $params 待签名参数 + * @param string $key 密钥 + * @return string 签名结果 + */ + public function sign(array $params, string $key): string; + + /** + * 验证签名。 + * + * @param array $params 待验签参数 + * @param string $sign 签名值 + * @param string $key 密钥 + * @return bool 是否通过 + */ + public function verify(array $params, string $sign, string $key): bool; +} diff --git a/app/service/payment/epay/EpaySignerManager.php b/app/service/payment/epay/EpaySignerManager.php new file mode 100644 index 0000000..e861c3f --- /dev/null +++ b/app/service/payment/epay/EpaySignerManager.php @@ -0,0 +1,75 @@ + $params 待签名参数 + * @param string $signType 签名类型 + * @param string $key 密钥 + * @return string 签名结果 + */ + public function sign(array $params, string $signType, string $key): string + { + return match ($this->normalizeSignType($signType)) { + AuthConstant::API_SIGN_NAME_MD5 => $this->md5Signer->sign($params, $key), + AuthConstant::API_SIGN_NORMALIZED_SHA256_WITH_RSA => $this->rsaSigner->sign($params, $key), + default => throw new PaymentException('不支持的签名类型', 40200), + }; + } + + /** + * 验证签名。 + * + * @param array $params 待验签参数 + * @param string $signType 签名类型 + * @param string $sign 签名值 + * @param string $key 密钥 + * @return bool 是否通过 + */ + public function verify(array $params, string $signType, string $sign, string $key): bool + { + return match ($this->normalizeSignType($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), + 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/EpayV1ProtocolService.php b/app/service/payment/epay/EpayV1ProtocolService.php new file mode 100644 index 0000000..80f0db6 --- /dev/null +++ b/app/service/payment/epay/EpayV1ProtocolService.php @@ -0,0 +1,864 @@ +prepareCashierSubmit($payload, $request); + $targetUrl = (string) ($attempt['cashier_url'] ?? ''); + if ($targetUrl === '') { + throw new ValidationException('收银台跳转地址生成失败'); + } + + return redirect($targetUrl); + } + + return $this->buildBrowserSubmitResponse($this->prepareSubmitAttempt($payload, $request)); + } catch (Throwable $e) { + return json([ + 'code' => 0, + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + /** + * 处理 API 支付入口。 + * + * @param array $payload 请求载荷 + * @param Request $request 请求对象 + * @return array ePay 风格响应 + */ + public function mapi(array $payload, Request $request): array + { + try { + $attempt = $this->prepareSubmitAttempt($payload, $request); + return $this->buildMapiResponse($attempt); + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + } + } + + /** + * 处理旧版兼容入口。 + * + * 支持 `query`、`settle`、`order`、`orders` 和 `refund` 五种操作。 + * + * @param array $payload 请求载荷 + * @return array ePay 风格响应 + */ + 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), + 'settle' => $this->querySettlementList($payload), + 'order' => $this->queryOrder($payload), + 'orders' => $this->queryOrders($payload), + 'refund' => $this->createRefund($payload), + }; + } + + /** + * 查询商户信息,对应 `act=query`。 + * + * @param array $payload 请求载荷 + * @return array ePay 风格响应 + */ + public function queryMerchantInfo(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $auth = $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $merchant = $auth['merchant']; + $credential = $auth['credential']; + $account = $this->merchantAccountRepository->findByMerchantId($merchantId); + // 旧协议会同时返回总单量、今日单量和昨日单量,便于上游直接做商户概览。 + $todayDate = FormatHelper::timestamp(time(), 'Y-m-d'); + $lastDayDate = FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d'); + $totalOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->count(); + $todayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $todayDate)->count(); + $lastDayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $lastDayDate)->count(); + + return [ + 'code' => 1, + 'pid' => (int) $merchant->id, + 'key' => (string) $credential->api_key, + 'active' => (int) $merchant->status, + 'money' => FormatHelper::amount((int) ($account->available_balance ?? 0)), + 'type' => (int) ($merchant->settle_type ?? 4), + 'account' => (string) $merchant->settlement_account_no, + 'username' => (string) $merchant->settlement_account_name, + 'orders' => $totalOrders, + 'order_today' => $todayOrders, + 'order_lastday' => $lastDayOrders, + ]; + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + } + } + + /** + * 查询结算记录列表,对应 `act=settle`。 + * + * @param array $payload 请求载荷 + * @return array ePay 风格响应 + */ + public function querySettlementList(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $rows = $this->settlementOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->get(); + + // 旧协议列表只需要基础字段和金额文本,这里直接整理成可展示数组。 + return [ + 'code' => 1, + 'msg' => '查询结算记录成功!', + 'data' => $rows->map(function ($row): array { + return [ + 'settle_no' => (string) $row->settle_no, + 'cycle_type' => (int) $row->cycle_type, + 'cycle_key' => (string) $row->cycle_key, + 'status' => (int) $row->status, + 'gross_amount' => FormatHelper::amount((int) $row->gross_amount), + 'net_amount' => FormatHelper::amount((int) $row->net_amount), + 'accounted_amount' => FormatHelper::amount((int) $row->accounted_amount), + 'created_at' => FormatHelper::dateTime($row->created_at ?? null), + 'completed_at' => FormatHelper::dateTime($row->completed_at ?? null), + ]; + })->all(), + ]; + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + } + } + + /** + * 查询单个订单,对应 `act=order`。 + * + * @param array $payload 请求载荷 + * @return array ePay 风格响应 + */ + public function queryOrder(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + $context = $this->resolvePayOrderContext($merchantId, $payload); + if (!$context) { + return ['code' => 0, 'msg' => '订单不存在']; + } + + // 旧协议查询单号时,要把支付单和业务单合并成同一份响应结构。 + return ['code' => 1, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']); + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + } + } + + /** + * 批量查询订单,对应 `act=orders`。 + * + * @param array $payload 请求载荷 + * @return array ePay 风格响应 + */ + public 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)); + $paginator = $this->payOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->paginate($limit, ['*'], 'page', $page); + $items = $paginator->items(); + $bizOrderMap = []; + $bizNos = array_values(array_unique(array_filter(array_map(function ($row): string { + return trim((string) ($row->biz_no ?? '')); + }, $items)))); + + if ($bizNos !== []) { + foreach ($this->bizOrderRepository->query()->whereIn('biz_no', $bizNos)->get() as $bizOrder) { + $bizOrderMap[(string) $bizOrder->biz_no] = $bizOrder; + } + } + + return [ + 'code' => 1, + 'msg' => '查询结算记录成功!', + // 批量查询和单条查询共用同一套格式化器,避免字段口径不一致。 + 'data' => array_map(function ($row) use ($bizOrderMap): array { + $bizNo = (string) ($row->biz_no ?? ''); + + return $this->formatEpayOrderRow($row, $bizOrderMap[$bizNo] ?? null); + }, $items), + ]; + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + } + } + + /** + * 提交退款申请,对应 `act=refund`。 + * + * @param array $payload 请求载荷 + * @return array ePay 风格响应 + */ + public function createRefund(array $payload): array + { + try { + $merchantId = (int) ($payload['pid'] ?? 0); + $key = trim((string) ($payload['key'] ?? '')); + $this->merchantApiCredentialService->authenticateByKey($merchantId, $key); + // 先确认退款目标单据归属当前商户,避免旧协议拿着别人的单号误发退款。 + $context = $this->resolvePayOrderContext($merchantId, $payload); + if (!$context) { + return ['code' => 0, '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, + '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'] ?? '退款失败')]; + } + + $this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [ + 'succeeded_at' => $this->now(), + 'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult), + ]); + + return ['code' => 1, 'msg' => '退款成功']; + } catch (Throwable $e) { + return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e)]; + } + } + + /** + * 预处理支付提交请求。 + * + * 这里负责把旧协议载荷转换为当前支付单创建所需的数据结构。 + * + * @param array $payload 请求载荷 + * @param Request $request 请求对象 + * @return array 预处理数据 + */ + private function prepareSubmitAttempt(array $payload, Request $request): array + { + // 先把旧协议载荷转换成当前系统的统一入参,再交给支付单主流程处理。 + $normalized = $this->normalizeSubmitPayload($payload, $request, false); + $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_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no), + ]; + } + + /** + * 预创建收银台业务单。 + * + * @param array $payload 请求载荷 + * @param Request $request 请求对象 + * @return array 预处理数据 + */ + private function prepareCashierSubmit(array $payload, Request $request): array + { + $normalized = $this->normalizeSubmitPayload($payload, $request, true); + $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'] ?? ''), + ]; + } + + /** + * 归一化提交支付参数。 + * + * 这里会完成签名校验、金额转分、支付方式解析,并把旧协议字段写入扩展信息。 + * + * @param array $payload 请求载荷 + * @param Request $request 请求对象 + * @return array 当前支付单创建参数 + * @throws ValidationException + */ + private function normalizeSubmitPayload(array $payload, Request $request, bool $allowEmptyType = false): array + { + $merchantId = (int) ($payload['pid'] ?? 0); + if ($merchantId <= 0) { + throw new ValidationException('pid 参数不能为空'); + } + + $sign = trim((string) ($payload['sign'] ?? '')); + if ($sign === '') { + throw new ValidationException('sign 参数不能为空'); + } + + $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, + (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); + } + + if ($merchantOrderNo === '') { + throw new ValidationException('out_trade_no 参数不能为空'); + } + if ($subject === '') { + throw new ValidationException('name 参数不能为空'); + } + if ($amount <= 0) { + throw new ValidationException('money 参数不合法'); + } + + // 旧协议的展示字段统一交给 assembler,避免 submit / mapi / cashier 三处口径漂移。 + $orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [ + '_protocol_version' => 'v1', + ]); + + $normalized = [ + 'merchant_id' => (int) ($payload['pid'] ?? 0), + 'merchant_order_no' => $merchantOrderNo, + 'pay_amount' => $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'], + ]; + + if ($paymentType) { + $normalized['pay_type_id'] = (int) $paymentType->id; + } + + return $normalized; + } + + /** + * 过滤旧协议签名参数。 + * + * @param array $payload 请求载荷 + * @return array 签名参数 + */ + private function buildV1SignParams(array $payload): array + { + $params = $payload; + unset($params['sign'], $params['sign_type'], $params['key']); + + foreach ($params as $paramKey => $paramValue) { + if ($paramValue === '' || $paramValue === null) { + unset($params[$paramKey]); + } + } + + 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 返回结构。 + * + * 根据当前支付尝试结果,输出 payurl、qrcode 或 urlscheme 等旧协议字段。 + * + * @param array $attempt 支付尝试结果 + * @return array ePay 风格响应 + */ + private function buildMapiResponse(array $attempt): array + { + /** @var PayOrder $payOrder */ + $payOrder = $attempt['pay_order']; + $payParams = (array) ($attempt['pay_params'] ?? []); + $normalizedPayload = (array) ($attempt['normalized_payload'] ?? []); + $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); + + 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]; + } + + if ($type === 'qrcode') { + $qrcode = $this->stringifyValue($payParams['qrcode_url'] ?? ''); + if ($qrcode !== '') { + return ['field' => 'qrcode', 'value' => $qrcode]; + } + } + + if ($type === 'jsapi') { + $urlscheme = $this->stringifyValue($payParams['order_string'] ?? ''); + if ($urlscheme !== '') { + return ['field' => 'urlscheme', 'value' => $urlscheme]; + } + } + + return ['field' => 'payurl', 'value' => $paymentPageUrl]; + } + + /** + * 将当前支付单格式化为旧版订单查询结构。 + * + * @param PayOrder $payOrder 支付订单 + * @param BizOrder|null $bizOrder 业务订单 + * @return array 旧版订单结构 + */ + private function formatEpayOrderRow(PayOrder $payOrder, ?BizOrder $bizOrder = null): array + { + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $extJson = (array) (($bizOrder?->ext_json) ?? []); + $merchantExt = (array) ($extJson['merchant'] ?? []); + + return [ + 'trade_no' => (string) $payOrder->pay_no, + 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? $extJson['merchant_order_no'] ?? ''), + 'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''), + 'type' => $this->resolvePaymentTypeCode((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'] ?? ''), + 'money' => FormatHelper::amount((int) $payOrder->pay_amount), + 'status' => (int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS ? 1 : 0, + 'param' => $this->stringifyValue($merchantExt['param'] ?? ''), + 'buyer' => $this->stringifyValue($merchantExt['buyer'] ?? ''), + ]; + } + + /** + * 解析支付订单上下文。 + * + * 优先按 `trade_no` 查找,其次按 `out_trade_no` 回退,并校验订单归属当前商户。 + * + * @param int $merchantId 商户ID + * @param array $payload 请求载荷 + * @return array{pay_order: PayOrder, biz_order: BizOrder|null}|null 上下文 + */ + private function resolvePayOrderContext(int $merchantId, array $payload): ?array + { + $payNo = trim((string) ($payload['trade_no'] ?? '')); + $merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? '')); + $payOrder = null; + $bizOrder = null; + + if ($payNo !== '') { + // 旧协议如果传了 trade_no,就优先按支付单号定位,命中率最高。 + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if ($payOrder) { + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + } + } + + if (!$payOrder && $merchantOrderNo !== '') { + // 没有 trade_no 时,再按商户单号反查业务单和最新支付单。 + $bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo); + if ($bizOrder) { + // 旧协议经常只传商户单号,这里拿业务单找到最新一笔支付单。 + $payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no); + } + } + + // 旧协议有时会传到别家商户的单号,这里必须再次校验归属,避免跨商户读取。 + if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) { + return null; + } + + if (!$bizOrder) { + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + } + + 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); + } + + /** + * 将元金额转成分。 + * + * @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'); + + // 旧协议金额按“元”传入,内部统一转成“分”处理,避免 float 精度漂移。 + 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() : '请求失败'; + } + + /** + * 构建支付页地址。 + * + * @param string $payNo 支付单号 + * @return string 支付页 URL + */ + private function buildPaymentPageUrl(string $payNo): string + { + 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; + } + + /** + * 将任意值规范化为字符串。 + * + * @param array|object|bool|float|int|string|null $value 待转换值 + * @return string 规范化后的字符串 + */ + private function stringifyValue(mixed $value): string + { + if ($value === null) { + return ''; + } + if (is_string($value)) { + return trim($value); + } + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + if (is_array($value) || is_object($value)) { + // 复杂结构直接 JSON 化,保证旧协议回显时仍然可读。 + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $json !== false ? $json : ''; + } + return (string) $value; + } + +} diff --git a/app/service/payment/epay/EpayV2ProtocolService.php b/app/service/payment/epay/EpayV2ProtocolService.php new file mode 100644 index 0000000..c46a2d9 --- /dev/null +++ b/app/service/payment/epay/EpayV2ProtocolService.php @@ -0,0 +1,996 @@ +prepareCashierSubmit($payload, $request); + $cashierUrl = (string) ($attempt['cashier_url'] ?? ''); + if ($cashierUrl === '') { + throw new ValidationException('收银台跳转地址生成失败'); + } + + return redirect($cashierUrl); + } + + return $this->buildBrowserSubmitResponse($this->preparePayAttempt($payload, $request, false)); + } catch (Throwable $e) { + return json($this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ])); + } + } + + public function create(array $payload, Request $request): array + { + try { + $attempt = $this->preparePayAttempt($payload, $request, true); + return $this->signResponse($this->buildCreateResponse($attempt)); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + public function query(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $context = $this->resolvePayOrderContext((int) $merchant->id, $payload); + if (!$context) { + throw new ResourceNotFoundException('订单不存在'); + } + + 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), + ]); + } + } + + public function refund(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $context = $this->resolvePayOrderContext((int) $merchant->id, $payload); + if (!$context) { + throw new ResourceNotFoundException('订单不存在'); + } + + /** @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, + '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'] ?? '退款失败')); + } + + $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), + ]); + } + } + + public function refundQuery(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $refundOrder = $this->resolveRefundOrder((int) $merchant->id, $payload); + $payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no); + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no); + + return $this->signResponse($this->buildRefundResponse($refundOrder, $payOrder, $bizOrder)); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + public function close(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $context = $this->resolvePayOrderContext((int) $merchant->id, $payload); + if (!$context) { + throw new ResourceNotFoundException('订单不存在'); + } + + /** @var PayOrder $payOrder */ + $payOrder = $context['pay_order']; + $currentStatus = (int) $payOrder->status; + if ($currentStatus === TradeConstant::ORDER_STATUS_CLOSED) { + return $this->signResponse([ + 'code' => $this->successCode(), + 'msg' => 'success', + ]); + } + + if ($currentStatus === TradeConstant::ORDER_STATUS_SUCCESS) { + throw new ValidationException('订单已支付成功,不能关闭'); + } + + if (TradeConstant::isOrderTerminalStatus($currentStatus)) { + throw new ValidationException('订单已结束,不能关闭'); + } + + $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); + $pluginResult = $plugin->close([ + '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), + 'extra' => (array) ($payOrder->ext_json ?? []), + ]); + + if (!$this->isPluginSuccess($pluginResult)) { + throw new ValidationException((string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '渠道关单失败')); + } + + $closeReason = (string) ($pluginResult['msg'] ?? 'ePay V2 手动关闭'); + $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(), + 'msg' => 'success', + ]); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + public function merchantInfo(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $account = $this->merchantAccountRepository->findByMerchantId((int) $merchant->id); + $today = $this->nowDate(); + $yesterday = $this->yesterdayDate(); + + $orderQuery = $this->payOrderRepository->query()->where('merchant_id', (int) $merchant->id); + + $totalOrders = (int) (clone $orderQuery)->count(); + $todayOrders = (int) (clone $orderQuery)->whereDate('created_at', $today)->count(); + $yesterdayOrders = (int) (clone $orderQuery)->whereDate('created_at', $yesterday)->count(); + $todayMoney = (int) (clone $orderQuery)->whereDate('created_at', $today)->sum('pay_amount'); + $yesterdayMoney = (int) (clone $orderQuery)->whereDate('created_at', $yesterday)->sum('pay_amount'); + + return $this->signResponse([ + 'code' => $this->successCode(), + 'msg' => 'success', + 'pid' => (int) $merchant->id, + 'status' => (int) $merchant->status, + 'pay_status' => (int) ($merchant->pay_status ?? 1), + 'settle_status' => (int) ($merchant->settle_status ?? 1), + 'money' => $this->formatAmount((int) ($account->available_balance ?? 0)), + 'settle_type' => (int) ($merchant->settle_type ?? 4), + 'settle_account' => (string) ($merchant->settlement_account_no ?? ''), + 'settle_name' => (string) ($merchant->settlement_account_name ?? ''), + 'order_num' => $totalOrders, + 'order_num_today' => $todayOrders, + 'order_num_lastday' => $yesterdayOrders, + 'order_money_today' => $this->formatAmount($todayMoney), + 'order_money_lastday' => $this->formatAmount($yesterdayMoney), + ]); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + 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)); + $page = (int) floor($offset / $limit) + 1; + $filters = []; + if (array_key_exists('status', $payload) && $payload['status'] !== '') { + $filters['status'] = (int) $payload['status']; + } + + $result = $this->payOrderQueryService->paginate($filters, $page, $limit, (int) $merchant->id); + + return $this->signResponse([ + 'code' => $this->successCode(), + 'msg' => 'success', + 'data' => $result['list'] ?? [], + ]); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + public function transferSubmit(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $data = $this->transferService->submit($merchant, $payload); + + return $this->signResponse([ + 'code' => $this->successCode(), + 'msg' => 'success', + 'status' => (int) ($data['status'] ?? 0), + 'biz_no' => (string) ($data['biz_no'] ?? ''), + 'out_biz_no' => (string) ($data['out_biz_no'] ?? ''), + 'orderid' => (string) ($data['orderid'] ?? ''), + 'paydate' => (string) ($data['paydate'] ?? ''), + 'cost_money' => (string) ($data['cost_money'] ?? ''), + ]); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + public function transferQuery(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $data = $this->transferService->query($merchant, $payload); + + return $this->signResponse([ + 'code' => $this->successCode(), + 'msg' => 'success', + ] + $data); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + public function transferBalance(array $payload): array + { + try { + $merchant = $this->authorizeMerchant($payload, true); + $data = $this->transferService->balance($merchant); + + return $this->signResponse([ + 'code' => $this->successCode(), + 'msg' => 'success', + ] + $data); + } catch (Throwable $e) { + return $this->signResponse([ + 'code' => $this->resolveFailureCode($e), + 'msg' => $this->normalizeErrorMessage($e), + ]); + } + } + + /** + * 预创建支付。 + * + * @param array $payload 请求参数 + * @param Request $request 请求对象 + * @param bool $requireType 是否强制要求 type + * @return array + */ + private function preparePayAttempt(array $payload, Request $request, bool $requireType): array + { + $merchant = $this->authorizeMerchant($payload, true); + $typeCode = trim((string) ($payload['type'] ?? '')); + if ($requireType && $typeCode === '') { + throw new ValidationException('type 不能为空'); + } + + $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 参数不合法'); + } + + // V2 直连支付和收银台确认共用同一套字段归一化逻辑。 + $orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [ + '_protocol_version' => 'v2', + ]); + $normalized = [ + 'merchant_id' => (int) $merchant->id, + 'merchant_order_no' => $merchantOrderNo, + 'pay_type_id' => (int) $paymentType->id, + 'pay_amount' => $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'], + 'channel_id' => (int) ($payload['channel_id'] ?? 0), + 'ext_json' => (array) $orderFields['ext_json'], + ]; + + $attempt = $this->payOrderService->preparePayAttempt($normalized); + $payOrder = $attempt['pay_order']; + + return [ + 'merchant' => $merchant, + 'pay_order' => $payOrder, + 'payment_result' => $attempt['payment_result'] ?? [], + 'pay_params' => $attempt['pay_params'] ?? [], + 'payment_page_url' => $this->buildPaymentPageUrl((string) $payOrder->pay_no), + ]; + } + + /** + * 预创建收银台业务单。 + * + * @param array $payload 请求参数 + * @param Request $request 请求对象 + * @return array + */ + private function prepareCashierSubmit(array $payload, Request $request): array + { + $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 参数不合法'); + } + + // 收银台首屏只需要业务单上下文,不在这里创建支付单。 + $orderFields = $this->orderInputAssembler->buildOrderFields($payload, $request, null, [ + '_protocol_version' => 'v2', + ]); + $normalized = [ + 'merchant_id' => (int) $merchant->id, + 'merchant_order_no' => $merchantOrderNo, + 'pay_amount' => $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'], + ]; + + $result = $this->payOrderService->prepareCashierBizOrder($normalized); + + return [ + 'merchant' => $merchant, + 'biz_order' => $result['biz_order'] ?? null, + 'cashier_url' => (string) ($result['cashier_url'] ?? ''), + ]; + } + + /** + * 构建创建支付响应。 + * + * @param array $attempt 支付尝试结果 + * @return array + */ + private function buildCreateResponse(array $attempt): array + { + /** @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, + ]; + } + + /** + * 解析支付上下文。 + * + * @param int $merchantId 商户ID + * @param array $payload 请求参数 + * @return array{pay_order: PayOrder, biz_order: BizOrder|null}|null + */ + private function resolvePayOrderContext(int $merchantId, array $payload): ?array + { + $payNo = trim((string) ($payload['trade_no'] ?? '')); + $merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? '')); + $payOrder = null; + $bizOrder = null; + + if ($payNo !== '') { + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if ($payOrder) { + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + } + } + + if (!$payOrder && $merchantOrderNo !== '') { + $bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo); + if ($bizOrder) { + $payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no); + } + } + + if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) { + return null; + } + + if (!$bizOrder) { + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + } + + return [ + 'pay_order' => $payOrder, + 'biz_order' => $bizOrder, + ]; + } + + /** + * 解析退款单。 + * + * @param int $merchantId 商户ID + * @param array $payload 请求参数 + * @return RefundOrder + */ + private function resolveRefundOrder(int $merchantId, array $payload): RefundOrder + { + $refundNo = trim((string) ($payload['refund_no'] ?? '')); + $outRefundNo = trim((string) ($payload['out_refund_no'] ?? '')); + + if ($refundNo !== '') { + $refundOrder = $this->refundOrderRepository->findByRefundNo($refundNo); + if (!$refundOrder || (int) $refundOrder->merchant_id !== $merchantId) { + throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]); + } + + return $refundOrder; + } + + if ($outRefundNo !== '') { + $refundOrder = $this->refundOrderRepository->findByMerchantRefundNo($merchantId, $outRefundNo); + if (!$refundOrder) { + throw new ResourceNotFoundException('退款单不存在', ['out_refund_no' => $outRefundNo]); + } + + return $refundOrder; + } + + throw new ValidationException('refund_no/out_refund_no 不能为空'); + } + + /** + * 构建订单响应。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array + */ + private function buildOrderResponse(PayOrder $payOrder, ?BizOrder $bizOrder = null): array + { + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $bizExtJson = (array) (($bizOrder?->ext_json) ?? []); + $merchantExt = (array) ($bizExtJson['merchant'] ?? []); + $refundAmount = (int) ($bizOrder?->refund_amount ?? 0); + + return [ + 'code' => $this->successCode(), + '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), + 'pid' => (int) $payOrder->merchant_id, + 'addtime' => FormatHelper::dateTime($payOrder->created_at), + 'endtime' => FormatHelper::dateTime($payOrder->paid_at), + 'name' => (string) ($bizOrder?->subject ?? ''), + 'money' => FormatHelper::amount((int) $payOrder->pay_amount), + 'refundmoney' => FormatHelper::amount($refundAmount), + 'param' => $this->stringifyValue($merchantExt['param'] ?? ''), + 'buyer' => $this->stringifyValue($merchantExt['buyer'] ?? ''), + 'clientip' => $this->stringifyValue($payOrder->client_ip ?? ''), + ]; + } + + /** + * 构建退款响应。 + * + * @param RefundOrder $refundOrder 退款单 + * @param PayOrder|null $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array + */ + private function buildRefundResponse(RefundOrder $refundOrder, ?PayOrder $payOrder = null, ?BizOrder $bizOrder = null): array + { + $payOrder ??= $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no); + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no); + + return [ + 'code' => $this->successCode(), + 'msg' => 'success', + 'refund_no' => (string) $refundOrder->refund_no, + 'out_refund_no' => (string) $refundOrder->merchant_refund_no, + 'trade_no' => (string) $refundOrder->pay_no, + 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''), + 'money' => FormatHelper::amount((int) $refundOrder->refund_amount), + 'reducemoney' => FormatHelper::amount((int) ($bizOrder?->refund_amount ?? 0)), + 'status' => (int) $refundOrder->status === TradeConstant::REFUND_STATUS_SUCCESS ? 1 : 0, + 'addtime' => FormatHelper::dateTime($refundOrder->created_at), + ]; + } + + /** + * 解析支付方式。 + * + * @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 + { + $merchantId = (int) ($payload['pid'] ?? 0); + if ($merchantId <= 0) { + throw new ValidationException('pid 不能为空'); + } + + $merchant = $this->merchantService->ensureMerchantEnabled($merchantId); + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + if (!$credential || (int) $credential->status !== AuthConstant::CREDENTIAL_STATUS_ENABLED) { + throw new ValidationException('商户 API 凭证未开通'); + } + + $publicKey = trim((string) ($credential->merchant_public_key ?? '')); + if ($publicKey === '') { + 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 校验失败'); + } + + $sign = trim((string) ($payload['sign'] ?? '')); + if ($sign === '') { + throw new ValidationException('sign 不能为空'); + } + + $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('签名验证失败'); + } + } + + return $merchant; + } + + /** + * 响应签名。 + * + * @param array $data 响应数据 + * @return array + */ + private function signResponse(array $data): array + { + $data['timestamp'] = (string) ($data['timestamp'] ?? time()); + $data['sign_type'] = $this->resolveResponseSignType(); + $privateKey = trim((string) config('epay.v2.platform_private_key', '')); + if ($privateKey === '') { + throw new ValidationException('平台 RSA 私钥未配置'); + } + + $signParams = $data; + unset($signParams['sign'], $signParams['sign_type']); + $data['sign'] = $this->signerManager->sign($signParams, $data['sign_type'], $privateKey); + + 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; + } + + /** + * 金额字符串转分。 + * + * @param string $money 金额字符串 + * @return int + */ + 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 mixed $value 值 + * @return string + */ + private function stringifyValue(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_array($value) || is_object($value)) { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $json !== false ? $json : ''; + } + + 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); + } + + /** + * 构建支付页地址。 + * + * @param string $payNo 支付单号 + * @return string + */ + private function buildPaymentPageUrl(string $payNo): string + { + 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/epay/Md5Signer.php b/app/service/payment/epay/Md5Signer.php new file mode 100644 index 0000000..885cf65 --- /dev/null +++ b/app/service/payment/epay/Md5Signer.php @@ -0,0 +1,38 @@ + $params 待签名参数 + * @param string $key 密钥 + * @return string 签名结果 + */ + public function sign(array $params, string $key): string + { + $content = $this->buildContent($params); + return md5($content . $key); + } + + /** + * 验证 MD5 签名。 + * + * @param array $params 待验签参数 + * @param string $sign 签名值 + * @param string $key 密钥 + * @return bool 是否通过 + */ + public function verify(array $params, string $sign, string $key): bool + { + $expected = $this->sign($params, $key); + return hash_equals(strtolower($expected), strtolower(trim($sign))); + } +} diff --git a/app/service/payment/epay/RsaSigner.php b/app/service/payment/epay/RsaSigner.php new file mode 100644 index 0000000..f132c95 --- /dev/null +++ b/app/service/payment/epay/RsaSigner.php @@ -0,0 +1,68 @@ + $params 待签名参数 + * @param string $key 私钥 + * @return string 签名结果 + */ + public function sign(array $params, string $key): string + { + $content = $this->buildContent($params); + $privateKey = $this->normalizePem($key, 'PRIVATE'); + if ($privateKey === '') { + throw new PaymentException('RSA 私钥不能为空', 40200); + } + + $resource = openssl_pkey_get_private($privateKey); + if ($resource === false) { + throw new PaymentException('签名失败,RSA 私钥无效', 40200); + } + + $result = openssl_sign($content, $signature, $resource, OPENSSL_ALGO_SHA256); + + if ($result !== true) { + throw new PaymentException('RSA 签名失败', 40200); + } + + return base64_encode($signature); + } + + /** + * 验证 RSA 签名。 + * + * @param array $params 待验签参数 + * @param string $sign 签名值 + * @param string $key 公钥 + * @return bool 是否通过 + */ + public function verify(array $params, string $sign, string $key): bool + { + $content = $this->buildContent($params); + $publicKey = $this->normalizePem($key, 'PUBLIC'); + if ($publicKey === '') { + return false; + } + + $resource = openssl_pkey_get_public($publicKey); + if ($resource === false) { + return false; + } + + $result = openssl_verify($content, base64_decode(trim($sign), true) ?: '', $resource, OPENSSL_ALGO_SHA256); + + return $result === 1; + } +} diff --git a/app/service/payment/order/PayOrderAttemptService.php b/app/service/payment/order/PayOrderAttemptService.php index 6d3e256..e26235c 100644 --- a/app/service/payment/order/PayOrderAttemptService.php +++ b/app/service/payment/order/PayOrderAttemptService.php @@ -10,6 +10,7 @@ use app\common\constant\TradeConstant; 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\repository\payment\config\PaymentTypeRepository; @@ -30,6 +31,7 @@ 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 @@ -43,6 +45,7 @@ class PayOrderAttemptService extends BaseService * @param BizOrderRepository $bizOrderRepository 业务订单仓库 * @param PayOrderRepository $payOrderRepository 支付订单仓库 * @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库 + * @param PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器 * @param PayOrderChannelDispatchService $payOrderChannelDispatchService 支付单渠道派发服务 */ public function __construct( @@ -52,6 +55,7 @@ class PayOrderAttemptService extends BaseService protected BizOrderRepository $bizOrderRepository, protected PayOrderRepository $payOrderRepository, protected PaymentTypeRepository $paymentTypeRepository, + protected PaymentOrderInputAssembler $orderInputAssembler, protected PayOrderChannelDispatchService $payOrderChannelDispatchService ) { } @@ -78,13 +82,7 @@ class PayOrderAttemptService extends BaseService throw new ValidationException('支付入参不完整'); } - // 先校验商户和支付方式是否可用,避免进入事务后才发现前置条件不满足。 - $merchant = $this->merchantService->ensureMerchantEnabled($merchantId); - $merchantGroupId = (int) $merchant->group_id; - if ($merchantGroupId <= 0) { - throw new ValidationException('商户未配置分组', ['merchant_id' => $merchantId]); - } - $this->merchantService->ensureMerchantGroupEnabled($merchantGroupId); + [$merchant, $merchantGroupId] = $this->resolveMerchantContext($merchantId); /** @var PaymentType|null $paymentType */ $paymentType = $this->paymentTypeRepository->find($payTypeId); @@ -92,11 +90,12 @@ class PayOrderAttemptService extends BaseService throw new BusinessStateException('支付方式不支持', ['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); $payNo = $this->generateNo('PAY'); $channelRequestNo = $this->generateNo('REQ'); @@ -112,7 +111,8 @@ class PayOrderAttemptService extends BaseService $route, $channel, $payNo, - $channelRequestNo + $channelRequestNo, + $bizFields ) { // 在事务中完成业务单和支付单的原子创建,保证幂等与状态一致。 $existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo); @@ -148,12 +148,19 @@ class PayOrderAttemptService extends BaseService } } + // 业务单一旦生成,订单展示字段与回调地址就不能在后续支付尝试里漂移。 + $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 { @@ -161,22 +168,36 @@ class PayOrderAttemptService extends BaseService 'biz_no' => $this->generateNo('BIZ'), 'trace_no' => $this->generateNo('TRC'), 'merchant_id' => $merchantId, - 'merchant_group_id' => $merchantGroupId, - 'poll_group_id' => (int) $route['poll_group']->id, 'merchant_order_no' => $merchantOrderNo, - 'subject' => (string) ($input['subject'] ?? ''), - 'body' => (string) ($input['body'] ?? ''), + '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' => $input['ext_json'] ?? [], + 'ext_json' => $bizFields['ext_json'], ]); $bizTraceNo = (string) $bizOrder->trace_no; $attemptNo = 1; } + // 支付单快照要以“当前请求 + 已确认业务单”为准,避免复用旧业务单时把上下文写空。 + $payOrderSeedExtJson = array_replace_recursive( + (array) ($bizOrder->ext_json ?? []), + (array) ($input['ext_json'] ?? []) + ); + $payOrderFields = $this->orderInputAssembler->buildOrderFields( + $input, + null, + $bizOrder, + $payOrderSeedExtJson + ); + $feeRateBp = (int) $channel->cost_rate_bp; $splitRateBp = (int) $channel->split_rate_bp ?: 10000; // 手续费和分账费率都按快照落库,后续配置变化不会影响这笔单的口径。 @@ -200,6 +221,7 @@ class PayOrderAttemptService extends BaseService } $payOrder = $this->payOrderRepository->create([ + // 路由与通道快照只落在支付单里,业务单保持纯业务事实。 'pay_no' => $payNo, 'biz_no' => (string) $bizOrder->biz_no, 'trace_no' => $bizTraceNo, @@ -213,6 +235,10 @@ class PayOrderAttemptService extends BaseService '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, @@ -224,21 +250,12 @@ class PayOrderAttemptService extends BaseService 'request_at' => $this->now(), 'callback_status' => NotifyConstant::PROCESS_STATUS_PENDING, 'callback_times' => 0, - 'ext_json' => array_merge($input['ext_json'] ?? [], [ - 'merchant_no' => (string) $merchant->merchant_no, - 'merchant_group_id' => $merchantGroupId, - 'poll_group_id' => (int) $route['poll_group']->id, - 'channel_id' => (int) $channel->id, - 'channel_mode' => (int) $channel->channel_mode, - 'trace_no' => $bizTraceNo, - ]), + '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; - $bizOrder->merchant_group_id = $merchantGroupId; - $bizOrder->poll_group_id = (int) $route['poll_group']->id; if ($bizTraceNo !== '' && (string) ($bizOrder->trace_no ?? '') === '') { // 把追踪号回写到业务单上,后续查单和对账能串到同一条链路。 $bizOrder->trace_no = $bizTraceNo; @@ -261,7 +278,9 @@ class PayOrderAttemptService extends BaseService $channel = $prepared['route']['selected_channel']['channel']; // 支付单落库后立即拉起渠道订单,补全渠道返回的单号和参数快照。 - $channelDispatchResult = $this->payOrderChannelDispatchService->dispatch($payOrder, $bizOrder, $channel); + /** @var \app\model\merchant\Merchant $merchant */ + $merchant = $prepared['merchant']; + $channelDispatchResult = $this->payOrderChannelDispatchService->dispatch($payOrder, $bizOrder, $channel, $merchant); $prepared['pay_order'] = $channelDispatchResult['pay_order']; $prepared['payment_result'] = $channelDispatchResult['payment_result']; @@ -270,6 +289,215 @@ 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), + ]; + } + + /** + * 解析商户和商户分组。 + * + * @param int $merchantId 商户ID + * @return array{0: Merchant, 1: int} 商户和商户分组ID + * @throws ValidationException + */ + private function resolveMerchantContext(int $merchantId): array + { + $merchant = $this->merchantService->ensureMerchantPayEnabled($merchantId); + $merchantGroupId = (int) $merchant->group_id; + if ($merchantGroupId <= 0) { + throw new ValidationException('商户未配置分组', ['merchant_id' => $merchantId]); + } + + $this->merchantService->ensureMerchantGroupEnabled($merchantGroupId); + + return [$merchant, $merchantGroupId]; + } + + /** + * 归一化业务单字段。 + * + * @param array $input 统一入参 + * @return array 业务单字段 + */ + private function buildBizOrderFields(array $input): array + { + // 业务单只保存商户业务上下文;支付载体上下文留给 PayOrder,避免同一业务单多次尝试时互相污染。 + $fields = $this->orderInputAssembler->buildOrderFields( + $input, + null, + null, + (array) ($input['ext_json'] ?? []) + ); + unset($fields['ext_json']['payment'], $fields['ext_json']['presentation'], $fields['ext_json']['plugin']); + + return $fields; + } + + /** + * 校验业务单关键字段是否与首次写入保持一致。 + * + * @param BizOrder $bizOrder 业务单 + * @param array $fields 当前请求整理后的字段 + * @return void + * @throws ConflictException + */ + private function assertBizOrderConsistency(BizOrder $bizOrder, array $fields): void + { + 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) { + throw new ConflictException('商户订单信息不一致', [ + 'biz_no' => (string) $bizOrder->biz_no, + 'field' => $field, + ]); + } + } + + $currentExtJson = $this->stableBizExtJson((array) ($bizOrder->ext_json ?? [])); + $incomingExtJson = $this->stableBizExtJson((array) ($fields['ext_json'] ?? [])); + if (!empty($currentExtJson) && !empty($incomingExtJson) && $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; + } + + /** + * 构建收银台跳转地址。 + * + * @param string $bizNo 业务单号 + * @return string 收银台 URL + */ + private function buildCashierPageUrl(string $bizNo): string + { + return (string) sys_config('site_url') . '/cashier/' . rawurlencode($bizNo); + } + /** * 计算手续费金额。 * diff --git a/app/service/payment/order/PayOrderCallbackService.php b/app/service/payment/order/PayOrderCallbackService.php index baf98df..84a0f6e 100644 --- a/app/service/payment/order/PayOrderCallbackService.php +++ b/app/service/payment/order/PayOrderCallbackService.php @@ -4,8 +4,10 @@ namespace app\service\payment\order; use app\common\base\BaseService; use app\common\constant\NotifyConstant; +use app\common\constant\PaymentPluginStatusConstant; use app\exception\PaymentException; use app\exception\ResourceNotFoundException; +use app\exception\ValidationException; use app\model\payment\PayOrder; use app\repository\payment\trade\PayOrderRepository; use app\service\payment\runtime\NotifyService; @@ -46,13 +48,13 @@ class PayOrderCallbackService extends BaseService * * @param array $input 回调载荷 * @return PayOrder 支付订单模型 - * @throws \InvalidArgumentException + * @throws ValidationException */ public function handleChannelCallback(array $input): PayOrder { $payNo = trim((string) ($input['pay_no'] ?? '')); if ($payNo === '') { - throw new \InvalidArgumentException('pay_no 不能为空'); + throw new ValidationException('pay_no 不能为空', ['pay_no' => $payNo]); } // 先落回调日志,后续无论成功还是失败,都可以从统一表里排查。 @@ -96,46 +98,48 @@ class PayOrderCallbackService extends BaseService $plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true); try { - // 由插件自行解析请求并返回统一结构,控制器层不直接判断渠道格式。 - $result = $plugin->notify($request); - $status = (string) ($result['status'] ?? ''); - // 老插件可能只返回 success / paid / failed 这类状态字符串,这里统一折算成布尔结果。 - $success = array_key_exists('success', $result) - ? (bool) $result['success'] - : in_array($status, ['success', 'paid'], true); + // 插件必须直接返回标准结构,系统层只负责校验,不再兼容旧字段别名。 + $result = $this->validatePluginNotifyResult($plugin->notify($request)); + $status = (string) $result['status']; // 将插件返回值归一化为生命周期服务可消费的回调载荷。 /** @var array $callbackPayload */ $callbackPayload = [ 'pay_no' => $payNo, - 'success' => $success, + 'success' => $status === PaymentPluginStatusConstant::SUCCESS, 'channel_id' => (int) $payOrder->channel_id, 'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC, - 'request_data' => array_merge($request->get(), $request->post()), + 'request_data' => $request->all(), 'verify_status' => NotifyConstant::VERIFY_STATUS_SUCCESS, - 'process_status' => $success ? NotifyConstant::PROCESS_STATUS_SUCCESS : NotifyConstant::PROCESS_STATUS_FAILED, + 'process_status' => $this->resolveProcessStatus($status), 'process_result' => $result, - 'channel_trade_no' => (string) ($result['chan_trade_no'] ?? ''), - 'channel_order_no' => (string) ($result['chan_order_no'] ?? ''), + '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'] ?? ''), - 'ext_json' => [ - 'plugin_code' => (string) $payOrder->plugin_code, - 'notify_status' => $status, - ], + // 回调原文和插件解析结果只进入 ma_pay_callback_log; + // 支付单本身只更新状态、渠道单号和错误字段,避免 ext_json 变成通知历史桶。 + 'ext_json' => [], ]; // 部分渠道会返回实际手续费,补充进回调载荷,便于后续清算和对账。 - if (isset($result['fee_actual_amount'])) { + 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 $success ? $plugin->notifySuccess() : $plugin->notifyFail(); + // 只要验签通过且已被系统处理,统一回成功响应,避免渠道对失败终态反复重推。 + return $plugin->notifySuccess(); } catch (PaymentException $e) { - // 插件已明确返回业务失败时,记录失败日志并按失败响应收口。 + // 验签失败或插件解析失败时,记录失败日志并返回失败响应,允许渠道按自身策略重推。 $this->notifyService->recordPayCallback([ 'pay_no' => $payNo, 'channel_id' => (int) $payOrder->channel_id, @@ -168,4 +172,89 @@ class PayOrderCallbackService extends BaseService 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, + ]); + } + } + + $status = strtolower(trim((string) $result['status'])); + if (!in_array($status, PaymentPluginStatusConstant::notifyStatuses(), true)) { + throw new PaymentException('插件回调返回的状态不合法', 40200, [ + 'status' => $status, + ]); + } + + $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; + } + + if (array_key_exists('ext_json', $result) && !is_array($result['ext_json'])) { + throw new PaymentException('插件回调 ext_json 必须为数组', 40200); + } + + $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'] ?? []), + ]; + } + + /** + * 根据插件标准状态映射日志处理状态。 + * + * @param string $status 标准状态 + * @return int + */ + private function resolveProcessStatus(string $status): int + { + return match ($status) { + PaymentPluginStatusConstant::SUCCESS => NotifyConstant::PROCESS_STATUS_SUCCESS, + PaymentPluginStatusConstant::FAILED => NotifyConstant::PROCESS_STATUS_FAILED, + default => NotifyConstant::PROCESS_STATUS_PENDING, + }; + } } diff --git a/app/service/payment/order/PayOrderChannelDispatchService.php b/app/service/payment/order/PayOrderChannelDispatchService.php index d5571d8..1779672 100644 --- a/app/service/payment/order/PayOrderChannelDispatchService.php +++ b/app/service/payment/order/PayOrderChannelDispatchService.php @@ -5,6 +5,7 @@ namespace app\service\payment\order; use app\common\base\BaseService; use app\exception\PaymentException; use app\exception\ResourceNotFoundException; +use app\model\merchant\Merchant; use app\model\payment\BizOrder; use app\model\payment\PayOrder; use app\model\payment\PaymentChannel; @@ -48,11 +49,12 @@ class PayOrderChannelDispatchService extends BaseService * @param PayOrder $payOrder 支付订单 * @param BizOrder $bizOrder 业务订单 * @param PaymentChannel $channel 渠道 + * @param Merchant $merchant 商户 * @return array 拉起结果 * @throws ResourceNotFoundException * @throws PaymentException */ - public function dispatch(PayOrder $payOrder, BizOrder $bizOrder, PaymentChannel $channel): array + public function dispatch(PayOrder $payOrder, BizOrder $bizOrder, PaymentChannel $channel, Merchant $merchant): array { try { // 先构造支付插件实例,由插件完成具体渠道下单。 @@ -60,31 +62,29 @@ class PayOrderChannelDispatchService extends BaseService /** @var PaymentType|null $paymentType */ $paymentType = $this->paymentTypeRepository->find((int) $payOrder->pay_type_id); $extJson = (array) ($payOrder->ext_json ?? []); - // 下单回调基址由支付单提前写入,这里拼出具体支付单回调地址交给插件使用。 - $callbackBaseUrl = trim((string) ($extJson['channel_callback_base_url'] ?? '')); - $callbackUrl = $callbackBaseUrl === '' - ? '' - : rtrim($callbackBaseUrl, '/') . '/' . $payOrder->pay_no . '/callback'; + $callbackUrl = rtrim(sys_config('site_url'), '/') . '/api/pay/' . $payOrder->pay_no . '/callback'; - // 插件下单参数里同时带业务单号、支付单号和扩展信息,方便渠道侧回调后能反查同一笔单。 - $channelResult = $plugin->pay([ + // 插件下单参数里同时带业务单号、支付单号和结构化扩展信息,方便渠道侧回调后能反查同一笔单。 + $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) ($extJson['merchant_no'] ?? ''), + '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, - 'return_url' => (string) ($extJson['return_url'] ?? ''), - '_env' => (string) (($extJson['device'] ?? '') ?: 'pc'), + '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) { // 回写渠道订单号和支付参数快照,便于后续查询和回调排障。 @@ -95,11 +95,16 @@ class PayOrderChannelDispatchService extends BaseService $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_merge((array) $latest->ext_json, [ - 'pay_params_type' => (string) (($channelResult['pay_params']['type'] ?? '') ?: ''), - 'pay_product' => (string) ($channelResult['pay_product'] ?? ''), - 'pay_action' => (string) ($channelResult['pay_action'] ?? ''), - 'pay_params_snapshot' => $this->normalizePayParamsSnapshot($channelResult['pay_params'] ?? []), + $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(); @@ -111,7 +116,9 @@ class PayOrderChannelDispatchService extends BaseService 'channel_error_msg' => $e->getMessage(), 'channel_error_code' => (string) $e->getCode(), 'ext_json' => [ - 'plugin_code' => (string) $payOrder->plugin_code, + 'plugin' => [ + 'code' => (string) $payOrder->plugin_code, + ], ], ]); @@ -122,20 +129,175 @@ class PayOrderChannelDispatchService extends BaseService 'channel_error_msg' => $e->getMessage(), 'channel_error_code' => 'PLUGIN_CREATE_ORDER_ERROR', 'ext_json' => [ - 'plugin_code' => (string) $payOrder->plugin_code, + 'plugin' => [ + 'code' => (string) $payOrder->plugin_code, + ], ], ]); - throw new PaymentException('创建第三方支付订单失败:' . $e->getMessage(), 40215); + throw new PaymentException('创建第三方支付订单失败', 40215, [ + 'error' => $e->getMessage(), + 'plugin_code' => (string) $payOrder->plugin_code, + ]); } return [ 'pay_order' => $payOrder, 'payment_result' => $channelResult, - 'pay_params' => $channelResult['pay_params'] ?? [], + 'pay_params' => $channelResult['pay_params'], ]; } + /** + * 校验并归一化插件下单返回值。 + * + * 插件返回值是支付页承接的唯一来源,必须在这里变成明确、可落库、可渲染的结构。 + * + * @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 支付参数 + * @return array + * @throws PaymentException + */ + private function validatePayParams(array $payParams): array + { + $type = strtolower(trim((string) ($payParams['type'] ?? ''))); + if ($type === '') { + throw new PaymentException('插件下单返回 pay_params.type 不能为空', 40200); + } + + $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, + ]); + } + + $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; + } + /** * 归一化支付参数快照,便于后续页面渲染和排障。 * @@ -156,9 +318,32 @@ class PayOrderChannelDispatchService extends BaseService return []; } + + /** + * 从候选字段中取首个非空文本。 + * + * @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 ($value === null) { + continue; + } + + if (is_scalar($value)) { + $text = trim((string) $value); + if ($text !== '') { + return $text; + } + } + } + + return ''; + } } - - - diff --git a/app/service/payment/order/PayOrderLifecycleService.php b/app/service/payment/order/PayOrderLifecycleService.php index 9d8e65c..4fcffe1 100644 --- a/app/service/payment/order/PayOrderLifecycleService.php +++ b/app/service/payment/order/PayOrderLifecycleService.php @@ -4,6 +4,7 @@ namespace app\service\payment\order; use app\common\base\BaseService; use app\common\constant\NotifyConstant; +use app\common\constant\EventConstant; use app\common\constant\RouteConstant; use app\common\constant\TradeConstant; use app\exception\BusinessStateException; @@ -11,6 +12,7 @@ use app\exception\ResourceNotFoundException; use app\model\payment\PayOrder; use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; +use Webman\Event\Event; /** * 支付单生命周期服务。 @@ -48,9 +50,17 @@ class PayOrderLifecycleService extends BaseService */ public function markPaySuccess(string $payNo, array $input = []): PayOrder { - return $this->transactionRetry(function () use ($payNo, $input) { - return $this->markPaySuccessInCurrentTransaction($payNo, $input); + $shouldNotifyMerchant = false; + + $payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldNotifyMerchant) { + return $this->markPaySuccessInCurrentTransaction($payNo, $input, $shouldNotifyMerchant); }); + + if ($shouldNotifyMerchant) { + $this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_SUCCEEDED, $payOrder); + } + + return $payOrder; } /** @@ -64,7 +74,7 @@ class PayOrderLifecycleService extends BaseService * @throws ResourceNotFoundException * @throws BusinessStateException */ - public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder + public function markPaySuccessInCurrentTransaction(string $payNo, array $input = [], bool &$shouldNotifyMerchant = false): PayOrder { $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); if (!$payOrder) { @@ -112,11 +122,12 @@ class PayOrderLifecycleService extends BaseService $payOrder->channel_error_code = ''; $payOrder->channel_error_msg = ''; $payOrder->callback_times = (int) $payOrder->callback_times + 1; - $payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []); + $payOrder->ext_json = array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []); $payOrder->save(); // 业务单状态也要一起收口,保证支付单和业务单一致。 $this->syncBizOrderAfterSuccess($payOrder, $traceNo); + $shouldNotifyMerchant = true; return $payOrder->refresh(); } @@ -130,9 +141,17 @@ class PayOrderLifecycleService extends BaseService */ public function markPayFailed(string $payNo, array $input = []): PayOrder { - return $this->transactionRetry(function () use ($payNo, $input) { - return $this->markPayFailedInCurrentTransaction($payNo, $input); + $shouldDispatchEvent = false; + + $payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldDispatchEvent) { + return $this->markPayFailedInCurrentTransaction($payNo, $input, $shouldDispatchEvent); }); + + if ($shouldDispatchEvent) { + $this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_FAILED, $payOrder); + } + + return $payOrder; } /** @@ -144,7 +163,7 @@ class PayOrderLifecycleService extends BaseService * @throws ResourceNotFoundException * @throws BusinessStateException */ - public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder + public function markPayFailedInCurrentTransaction(string $payNo, array $input = [], bool &$shouldDispatchEvent = false): PayOrder { $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); if (!$payOrder) { @@ -177,15 +196,18 @@ class PayOrderLifecycleService extends BaseService : TradeConstant::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->failed_at = $input['failed_at'] ?? $this->now(); $payOrder->callback_times = (int) $payOrder->callback_times + 1; - $payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []); + $payOrder->ext_json = array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []); $payOrder->save(); // 支付单进入终态后,同步回业务单,避免上游只能依赖支付单判断结果。 $this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_FAILED, 'failed_at'); + $shouldDispatchEvent = true; return $payOrder->refresh(); } @@ -199,9 +221,17 @@ class PayOrderLifecycleService extends BaseService */ public function closePayOrder(string $payNo, array $input = []): PayOrder { - return $this->transactionRetry(function () use ($payNo, $input) { - return $this->closePayOrderInCurrentTransaction($payNo, $input); + $shouldDispatchEvent = false; + + $payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldDispatchEvent) { + return $this->closePayOrderInCurrentTransaction($payNo, $input, $shouldDispatchEvent); }); + + if ($shouldDispatchEvent) { + $this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_CLOSED, $payOrder); + } + + return $payOrder; } /** @@ -213,7 +243,7 @@ class PayOrderLifecycleService extends BaseService * @throws ResourceNotFoundException * @throws BusinessStateException */ - public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder + public function closePayOrderInCurrentTransaction(string $payNo, array $input = [], bool &$shouldDispatchEvent = false): PayOrder { $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); if (!$payOrder) { @@ -249,13 +279,16 @@ class PayOrderLifecycleService extends BaseService $extJson = (array) $payOrder->ext_json; $reason = trim((string) ($input['reason'] ?? '')); if ($reason !== '') { - $extJson['close_reason'] = $reason; + $extJson['lifecycle'] = array_replace((array) ($extJson['lifecycle'] ?? []), [ + 'close_reason' => $reason, + ]); } - $payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []); + $payOrder->ext_json = array_replace_recursive($extJson, $input['ext_json'] ?? []); $payOrder->save(); // 关闭态也要同步给业务单,避免后续继续拉起支付。 $this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_CLOSED, 'closed_at'); + $shouldDispatchEvent = true; return $payOrder->refresh(); } @@ -269,9 +302,17 @@ class PayOrderLifecycleService extends BaseService */ public function timeoutPayOrder(string $payNo, array $input = []): PayOrder { - return $this->transactionRetry(function () use ($payNo, $input) { - return $this->timeoutPayOrderInCurrentTransaction($payNo, $input); + $shouldDispatchEvent = false; + + $payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldDispatchEvent) { + return $this->timeoutPayOrderInCurrentTransaction($payNo, $input, $shouldDispatchEvent); }); + + if ($shouldDispatchEvent) { + $this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_TIMEOUT, $payOrder); + } + + return $payOrder; } /** @@ -283,7 +324,7 @@ class PayOrderLifecycleService extends BaseService * @throws ResourceNotFoundException * @throws BusinessStateException */ - public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder + public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = [], bool &$shouldDispatchEvent = false): PayOrder { $payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo); if (!$payOrder) { @@ -319,12 +360,15 @@ class PayOrderLifecycleService extends BaseService $extJson = (array) $payOrder->ext_json; $reason = trim((string) ($input['reason'] ?? '')); if ($reason !== '') { - $extJson['timeout_reason'] = $reason; + $extJson['lifecycle'] = array_replace((array) ($extJson['lifecycle'] ?? []), [ + 'timeout_reason' => $reason, + ]); } - $payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []); + $payOrder->ext_json = array_replace_recursive($extJson, $input['ext_json'] ?? []); $payOrder->save(); $this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_TIMEOUT, 'timeout_at'); + $shouldDispatchEvent = true; return $payOrder->refresh(); } @@ -380,4 +424,20 @@ class PayOrderLifecycleService extends BaseService $bizOrder->save(); } + /** + * 发送支付单事件。 + * + * @param string $eventName 事件名称 + * @param PayOrder $payOrder 支付订单 + * @return void + */ + private function dispatchPayOrderEvent(string $eventName, PayOrder $payOrder): void + { + Event::dispatch($eventName, [ + 'pay_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'pay_order' => $payOrder, + ]); + } + } diff --git a/app/service/payment/order/PayOrderQueryService.php b/app/service/payment/order/PayOrderQueryService.php index 914d02b..deda637 100644 --- a/app/service/payment/order/PayOrderQueryService.php +++ b/app/service/payment/order/PayOrderQueryService.php @@ -4,12 +4,14 @@ namespace app\service\payment\order; use app\common\base\BaseService; use app\common\constant\CommonConstant; +use app\common\constant\NotifyConstant; use app\exception\ResourceNotFoundException; use app\exception\ValidationException; use app\model\payment\PayOrder; use app\model\payment\PaymentType; use app\repository\account\ledger\MerchantAccountLedgerRepository; use app\repository\payment\config\PaymentTypeRepository; +use app\repository\payment\notify\NotifyTaskRepository; use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; @@ -22,6 +24,7 @@ use app\repository\payment\trade\PayOrderRepository; * @property BizOrderRepository $bizOrderRepository 业务订单仓库 * @property MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库 * @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库 + * @property NotifyTaskRepository $notifyTaskRepository 通知任务仓库 * @property PayOrderReportService $payOrderReportService 支付单报表服务 */ class PayOrderQueryService extends BaseService @@ -33,6 +36,7 @@ class PayOrderQueryService extends BaseService * @param BizOrderRepository $bizOrderRepository 业务订单仓库 * @param MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库 * @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库 + * @param NotifyTaskRepository $notifyTaskRepository 通知任务仓库 * @param PayOrderReportService $payOrderReportService 支付单报表服务 * @return void */ @@ -41,6 +45,7 @@ class PayOrderQueryService extends BaseService protected BizOrderRepository $bizOrderRepository, protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository, protected PaymentTypeRepository $paymentTypeRepository, + protected NotifyTaskRepository $notifyTaskRepository, protected PayOrderReportService $payOrderReportService ) { } @@ -58,6 +63,148 @@ class PayOrderQueryService extends BaseService * @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 + { + $query = $this->buildPayOrderQuery($merchantId); + + $keyword = trim((string) ($filters['keyword'] ?? '')); + if ($keyword !== '') { + // 关键词同时命中支付单、业务单、商户、通道和支付方式,方便后台一把搜全链路。 + $query->where(function ($builder) use ($keyword) { + $builder->where('po.pay_no', 'like', '%' . $keyword . '%') + ->orWhere('po.biz_no', 'like', '%' . $keyword . '%') + ->orWhere('po.trace_no', 'like', '%' . $keyword . '%') + ->orWhere('po.channel_request_no', 'like', '%' . $keyword . '%') + ->orWhere('po.channel_order_no', 'like', '%' . $keyword . '%') + ->orWhere('po.channel_trade_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%') + ->orWhere('bo.subject', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') + ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') + ->orWhere('g.group_name', 'like', '%' . $keyword . '%') + ->orWhere('c.name', 'like', '%' . $keyword . '%') + ->orWhere('t.name', 'like', '%' . $keyword . '%'); + }); + } + + if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) { + $query->where('po.merchant_id', $merchantFilter); + } + + if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { + $query->where('po.pay_type_id', $payTypeId); + } + + if (array_key_exists('status', $filters) && $filters['status'] !== '') { + $query->where('po.status', (int) $filters['status']); + } + + if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') { + $query->where('po.channel_mode', (int) $filters['channel_mode']); + } + + if (array_key_exists('callback_status', $filters) && $filters['callback_status'] !== '') { + $query->where('po.callback_status', (int) $filters['callback_status']); + } + + $paginator = $query + ->orderByDesc('po.id') + ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); + + $list = []; + foreach ($paginator->items() as $item) { + $list[] = $this->payOrderReportService->formatPayOrderRow($this->rowToArray($item)); + } + + return [ + 'list' => $list, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'size' => $paginator->perPage(), + 'pay_types' => $this->payTypeOptions(), + ]; + } + + /** + * 查询支付订单详情。 + * + * 返回支付单、业务单、时间线和资金流水,供管理后台与商户后台共用。 + * + * @param string $payNo 支付单号 + * @param int|null $merchantId 商户ID + * @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 + { + $payNo = trim($payNo); + if ($payNo === '') { + throw new ValidationException('pay_no 不能为空'); + } + + $payOrder = $this->payOrderRepository->findByPayNo($payNo); + if (!$payOrder) { + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + if ($merchantId !== null && $merchantId > 0 && (int) $payOrder->merchant_id !== $merchantId) { + // 商户后台只允许看自己的单,归属不匹配时直接按不存在处理。 + throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); + } + + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $detailRow = $this->buildPayOrderQuery($merchantId) + ->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)); + } + + return [ + 'pay_order' => $payOrder, + 'biz_order' => $bizOrder, + 'pay_order_view' => $detailRow ? $this->payOrderReportService->formatPayOrderRow($this->rowToArray($detailRow)) : null, + 'timeline' => $timeline, + 'account_ledgers' => $accountLedgers, + 'account_ledgers_view' => $accountLedgerRows, + 'notify_tasks' => $this->loadNotifyTasks($payNo), + ]; + } + + /** + * 加载支付相关资金流水。 + * + * 优先按追踪号查询,追踪号为空时回退到业务单号,避免漏掉关联流水。 + * + * @param PayOrder $payOrder 支付订单 + * @return \Illuminate\Support\Collection 支付相关资金流水集合 + */ + private function loadPayLedgers(PayOrder $payOrder) + { + $traceNo = trim((string) ($payOrder->trace_no ?: $payOrder->biz_no)); + $ledgers = $traceNo !== '' + ? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo) + : collect(); + + if ($ledgers->isEmpty()) { + // 追踪号没有命中时,回到业务单号继续兜底,避免早期单据漏掉资金流水。 + $ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->biz_no); + } + + return $ledgers; + } + + /** + * 查询支付单详情展示行,供列表与详情复用。 + * + * @param int|null $merchantId 商户ID + * @return \Illuminate\Database\Eloquent\Builder + */ + private function buildPayOrderQuery(?int $merchantId = null) { $query = $this->payOrderRepository->query() ->from('ma_pay_order as po') @@ -134,126 +281,38 @@ class PayOrderQueryService extends BaseService $query->where('po.merchant_id', $merchantId); } - $keyword = trim((string) ($filters['keyword'] ?? '')); - if ($keyword !== '') { - // 关键词同时命中支付单、业务单、商户、通道和支付方式,方便后台一把搜全链路。 - $query->where(function ($builder) use ($keyword) { - $builder->where('po.pay_no', 'like', '%' . $keyword . '%') - ->orWhere('po.biz_no', 'like', '%' . $keyword . '%') - ->orWhere('po.trace_no', 'like', '%' . $keyword . '%') - ->orWhere('po.channel_request_no', 'like', '%' . $keyword . '%') - ->orWhere('po.channel_order_no', 'like', '%' . $keyword . '%') - ->orWhere('po.channel_trade_no', 'like', '%' . $keyword . '%') - ->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%') - ->orWhere('bo.subject', 'like', '%' . $keyword . '%') - ->orWhere('m.merchant_no', 'like', '%' . $keyword . '%') - ->orWhere('m.merchant_name', 'like', '%' . $keyword . '%') - ->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%') - ->orWhere('g.group_name', 'like', '%' . $keyword . '%') - ->orWhere('c.name', 'like', '%' . $keyword . '%') - ->orWhere('t.name', 'like', '%' . $keyword . '%'); - }); - } - - if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) { - $query->where('po.merchant_id', $merchantFilter); - } - - if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) { - $query->where('po.pay_type_id', $payTypeId); - } - - if (array_key_exists('status', $filters) && $filters['status'] !== '') { - $query->where('po.status', (int) $filters['status']); - } - - if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') { - $query->where('po.channel_mode', (int) $filters['channel_mode']); - } - - if (array_key_exists('callback_status', $filters) && $filters['callback_status'] !== '') { - $query->where('po.callback_status', (int) $filters['callback_status']); - } - - $paginator = $query - ->orderByDesc('po.id') - ->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page)); - - $list = []; - foreach ($paginator->items() as $item) { - $list[] = $this->payOrderReportService->formatPayOrderRow((array) $item); - } - - return [ - 'list' => $list, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'size' => $paginator->perPage(), - 'pay_types' => $this->payTypeOptions(), - ]; + return $query; } /** - * 查询支付订单详情。 - * - * 返回支付单、业务单、时间线和资金流水,供管理后台与商户后台共用。 + * 加载并格式化通知任务列表。 * * @param string $payNo 支付单号 - * @param int|null $merchantId 商户ID - * @return array{pay_order: PayOrder, biz_order: \app\model\payment\BizOrder|null, timeline: array>, account_ledgers: \Illuminate\Support\Collection} 支付详情结构 - * @throws ValidationException - * @throws ResourceNotFoundException + * @return array> */ - public function detail(string $payNo, ?int $merchantId = null): array + private function loadNotifyTasks(string $payNo): array { - $payNo = trim($payNo); - if ($payNo === '') { - throw new ValidationException('pay_no 不能为空'); + $rows = []; + foreach ($this->notifyTaskRepository->listByPayNo($payNo) as $task) { + $rows[] = [ + 'notify_no' => (string) $task->notify_no, + 'event_type' => (string) ($task->event_type ?? ''), + 'event_type_text' => (string) (NotifyConstant::eventTypeMap()[(string) ($task->event_type ?? '')] ?? ($task->event_type ?? '')), + 'ref_no' => (string) ($task->ref_no ?? ''), + 'notify_url' => (string) $task->notify_url, + 'status' => (int) $task->status, + 'status_text' => (string) (NotifyConstant::taskStatusMap()[(int) $task->status] ?? '未知'), + 'retry_count' => (int) $task->retry_count, + 'last_notify_at_text' => $this->formatDateTime($task->last_notify_at, '—'), + 'next_retry_at_text' => $this->formatDateTime($task->next_retry_at, '—'), + 'last_response' => (string) ($task->last_response ?? ''), + 'notify_data' => (array) ($task->notify_data ?? []), + 'created_at_text' => $this->formatDateTime($task->created_at, '—'), + 'updated_at_text' => $this->formatDateTime($task->updated_at, '—'), + ]; } - $payOrder = $this->payOrderRepository->findByPayNo($payNo); - if (!$payOrder) { - throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); - } - - if ($merchantId !== null && $merchantId > 0 && (int) $payOrder->merchant_id !== $merchantId) { - // 商户后台只允许看自己的单,归属不匹配时直接按不存在处理。 - throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]); - } - - $bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); - $timeline = $this->payOrderReportService->buildPayTimeline($payOrder); - $accountLedgers = $this->loadPayLedgers($payOrder); - - return [ - 'pay_order' => $payOrder, - 'biz_order' => $bizOrder, - 'timeline' => $timeline, - 'account_ledgers' => $accountLedgers, - ]; - } - - /** - * 加载支付相关资金流水。 - * - * 优先按追踪号查询,追踪号为空时回退到业务单号,避免漏掉关联流水。 - * - * @param PayOrder $payOrder 支付订单 - * @return \Illuminate\Support\Collection 支付相关资金流水集合 - */ - private function loadPayLedgers(PayOrder $payOrder) - { - $traceNo = trim((string) ($payOrder->trace_no ?: $payOrder->biz_no)); - $ledgers = $traceNo !== '' - ? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo) - : collect(); - - if ($ledgers->isEmpty()) { - // 追踪号没有命中时,回到业务单号继续兜底,避免早期单据漏掉资金流水。 - $ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->biz_no); - } - - return $ledgers; + return $rows; } /** @@ -278,4 +337,27 @@ 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 2e51b66..aaae68d 100644 --- a/app/service/payment/order/PayOrderReportService.php +++ b/app/service/payment/order/PayOrderReportService.php @@ -3,6 +3,7 @@ namespace app\service\payment\order; use app\common\base\BaseService; +use app\common\constant\LedgerConstant; use app\common\constant\NotifyConstant; use app\common\constant\RouteConstant; use app\common\constant\TradeConstant; @@ -72,36 +73,61 @@ class PayOrderReportService extends BaseService public function buildPayTimeline(PayOrder $payOrder): array { $extJson = (array) ($payOrder->ext_json ?? []); + $lifecycle = (array) ($extJson['lifecycle'] ?? []); // 只保留真实发生过的节点,未触发的状态直接过滤掉,避免时间线里出现空占位。 return array_values(array_filter([ [ 'status' => 'created', + 'label' => '支付单创建', 'at' => $this->formatDateTime($payOrder->request_at ?? $payOrder->created_at ?? null, '—'), ], $payOrder->paid_at ? [ 'status' => 'success', + 'label' => '支付成功', 'at' => $this->formatDateTime($payOrder->paid_at, '—'), ] : null, $payOrder->closed_at ? [ 'status' => 'closed', + 'label' => '支付关闭', 'at' => $this->formatDateTime($payOrder->closed_at, '—'), // 关闭原因优先取扩展信息里的记录,便于展示人工关单或自动关单原因。 - 'reason' => (string) ($extJson['close_reason'] ?? ''), + 'reason' => (string) ($lifecycle['close_reason'] ?? ''), ] : null, $payOrder->failed_at ? [ 'status' => 'failed', + 'label' => '支付失败', 'at' => $this->formatDateTime($payOrder->failed_at, '—'), // 失败原因先看渠道返回,再回落到扩展信息里保存的统一原因字段。 'reason' => (string) ($payOrder->channel_error_msg ?: ($extJson['reason'] ?? '')), ] : null, $payOrder->timeout_at ? [ 'status' => 'timeout', + 'label' => '支付超时', 'at' => $this->formatDateTime($payOrder->timeout_at, '—'), - 'reason' => (string) ($extJson['timeout_reason'] ?? ''), + 'reason' => (string) ($lifecycle['timeout_reason'] ?? ''), ] : null, ])); } + + /** + * 格式化支付相关资金流水。 + * + * @param array $row 原始流水数据 + * @return array + */ + public function formatLedgerRow(array $row): array + { + $row['biz_type_text'] = $this->textFromMap((int) ($row['biz_type'] ?? -1), LedgerConstant::bizTypeMap()); + $row['event_type_text'] = $this->textFromMap((int) ($row['event_type'] ?? -1), LedgerConstant::eventTypeMap()); + $row['direction_text'] = $this->textFromMap((int) ($row['direction'] ?? -1), LedgerConstant::directionMap()); + $row['amount_text'] = $this->formatAmount((int) ($row['amount'] ?? 0)); + $row['available_before_text'] = $this->formatAmount((int) ($row['available_before'] ?? 0)); + $row['available_after_text'] = $this->formatAmount((int) ($row['available_after'] ?? 0)); + $row['frozen_before_text'] = $this->formatAmount((int) ($row['frozen_before'] ?? 0)); + $row['frozen_after_text'] = $this->formatAmount((int) ($row['frozen_after'] ?? 0)); + $row['created_at_text'] = $this->formatDateTime($row['created_at'] ?? null, '—'); + + return $row; + } } - - diff --git a/app/service/payment/order/PayOrderService.php b/app/service/payment/order/PayOrderService.php index 5ce02a7..238ac96 100644 --- a/app/service/payment/order/PayOrderService.php +++ b/app/service/payment/order/PayOrderService.php @@ -70,6 +70,17 @@ class PayOrderService extends BaseService return $this->attemptService->preparePayAttempt($input); } + /** + * 预创建收银台业务单。 + * + * @param array $input 收银台数据 + * @return array 发起结果 + */ + public function prepareCashierBizOrder(array $input): array + { + return $this->attemptService->prepareCashierBizOrder($input); + } + /** * 标记支付成功。 * @@ -191,4 +202,3 @@ class PayOrderService extends BaseService } - diff --git a/app/service/payment/order/PaymentOrderInputAssembler.php b/app/service/payment/order/PaymentOrderInputAssembler.php new file mode 100644 index 0000000..352dcaa --- /dev/null +++ b/app/service/payment/order/PaymentOrderInputAssembler.php @@ -0,0 +1,140 @@ + $seedExtJson 需要合并到扩展参数中的种子数据 + * @return array + */ + public function buildOrderFields(array $payload, ?Request $request = null, ?BizOrder $bizOrder = null, array $seedExtJson = []): array + { + // 商品标题优先用显式入参,缺失时回退到业务单快照,保证收银台恢复时展示一致。 + $subject = trim((string) ($payload['subject'] ?? $payload['name'] ?? ($bizOrder?->subject ?? ''))); + // 商品描述尽量沿用同一份展示文案,避免不同入口出现两套口径。 + $body = trim((string) ($payload['body'] ?? $payload['subject'] ?? $payload['name'] ?? ($bizOrder?->body ?? ''))); + if ($body === '') { + $body = $subject; + } + + return [ + 'subject' => $subject, + 'body' => $body !== '' ? $body : $subject, + 'notify_url' => trim((string) ($payload['notify_url'] ?? ($bizOrder?->notify_url ?? ''))), + 'return_url' => trim((string) ($payload['return_url'] ?? ($bizOrder?->return_url ?? ''))), + 'client_ip' => $this->resolveClientIp($payload, $request, $bizOrder), + 'device' => $this->resolveDevice($payload, $bizOrder), + 'ext_json' => $this->buildExtJson($payload, $seedExtJson), + ]; + } + + /** + * 组装扩展参数。 + * + * 扩展参数按职责分区: + * - 顶层 `_protocol_version` 等强语义字段用于后台筛选和排障。 + * - `merchant` 只放商户透传字段,后续会参与商户通知回传。 + * - `payment` 只放本次支付载体需要的上下文,例如 JSAPI openid 或付款码。 + * + * @param array $payload 原始入参 + * @param array $seedExtJson 需要保留的扩展参数 + * @return array + */ + public function buildExtJson(array $payload, array $seedExtJson = []): array + { + $extJson = $seedExtJson; + + $merchant = array_filter([ + 'param' => $payload['param'] ?? null, + 'buyer' => $payload['buyer'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''); + + if ($merchant !== []) { + $extJson['merchant'] = array_replace((array) ($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'] = array_replace((array) ($extJson['payment'] ?? []), $payment); + } + + return $extJson; + } + + /** + * 解析客户端 IP。 + * + * @param array $payload 原始入参 + * @param Request|null $request 请求对象 + * @param BizOrder|null $bizOrder 业务单 + * @return string + */ + public function resolveClientIp(array $payload, ?Request $request = null, ?BizOrder $bizOrder = null): string + { + // 显式传入的 clientip / client_ip 优先级最高,兼容不同协议字段名。 + $clientIp = trim((string) ($payload['clientip'] ?? '')); + if ($clientIp !== '') { + return $clientIp; + } + + $clientIp = trim((string) ($payload['client_ip'] ?? '')); + if ($clientIp !== '') { + return $clientIp; + } + + if ($bizOrder && trim((string) ($bizOrder->client_ip ?? '')) !== '') { + return trim((string) $bizOrder->client_ip); + } + + // 最后才回退到请求源 IP,避免把代理层或网关层地址误当成业务上下文。 + if ($request) { + return trim((string) $request->getRealIp()); + } + + return ''; + } + + /** + * 解析设备类型。 + * + * @param array $payload 原始入参 + * @param BizOrder|null $bizOrder 业务单 + * @param string $default 默认设备类型 + * @return string + */ + public function resolveDevice(array $payload, ?BizOrder $bizOrder = null, string $default = 'pc'): string + { + // 设备类型先取请求参数,再用业务单快照兜底,最后才回落默认值。 + $device = trim((string) ($payload['device'] ?? '')); + if ($device !== '') { + return $device; + } + + if ($bizOrder && trim((string) ($bizOrder->device ?? '')) !== '') { + return trim((string) $bizOrder->device); + } + + return $default; + } +} diff --git a/app/service/payment/order/RefundCreationService.php b/app/service/payment/order/RefundCreationService.php index fe564f5..4af2f9e 100644 --- a/app/service/payment/order/RefundCreationService.php +++ b/app/service/payment/order/RefundCreationService.php @@ -9,7 +9,9 @@ use app\exception\BusinessStateException; use app\exception\ConflictException; use app\exception\ResourceNotFoundException; use app\exception\ValidationException; +use app\model\payment\BizOrder; use app\model\payment\RefundOrder; +use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; use app\repository\payment\trade\RefundOrderRepository; @@ -32,6 +34,7 @@ class RefundCreationService extends BaseService */ public function __construct( protected PayOrderRepository $payOrderRepository, + protected BizOrderRepository $bizOrderRepository, protected RefundOrderRepository $refundOrderRepository ) { } @@ -39,7 +42,7 @@ class RefundCreationService extends BaseService /** * 创建退款单。 * - * 当前仅支持整单全额退款,且同一支付单只能创建一张退款单。 + * 当前支持整单或部分退款,同一支付单可创建多张退款单。 * * @param array $input 退款参数 * @return RefundOrder 退款单记录 @@ -70,12 +73,27 @@ class RefundCreationService extends BaseService ]); } + /** @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('退款金额不合法'); + } - if ($refundAmount !== (int) $payOrder->pay_amount) { - throw new BusinessStateException('当前仅支持整单全额退款'); + $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, + ]); } // 业务系统若传了商户退款单号,就优先按商户幂等键查重。 @@ -97,25 +115,13 @@ class RefundCreationService extends BaseService } } - // 没有商户退款单号时,用支付单号兜底,避免同一支付单重复创建退款单。 - /** @var RefundOrder|null $existingByPayNo */ - $existingByPayNo = $this->refundOrderRepository->findByPayNo($payNo); - if ($existingByPayNo) { - if ($merchantRefundNo !== '' && (string) $existingByPayNo->merchant_refund_no !== $merchantRefundNo) { - throw new ConflictException('重复退款', ['pay_no' => $payNo]); - } - - return $existingByPayNo; - } - $traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no); - // 退款单落库时同步追踪号、渠道单号和反向手续费,方便后续退款推进与对账。 - /** @var int $feeReverseAmount */ - $feeReverseAmount = ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT) - ? (int) $payOrder->fee_actual_amount - : 0; - // 代收场景下,退款需要把实际手续费作为反向金额记录下来,后续成功态才能正确冲正余额。 + $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)); + } + return $this->refundOrderRepository->create([ 'refund_no' => $this->generateNo('RFD'), 'merchant_id' => (int) $payOrder->merchant_id, @@ -134,9 +140,7 @@ class RefundCreationService extends BaseService 'processing_at' => null, 'retry_count' => 0, 'last_error' => '', - 'ext_json' => array_merge($input['ext_json'] ?? [], [ - 'trace_no' => $traceNo, - ]), + 'ext_json' => (array) ($input['ext_json'] ?? []), ]); } } diff --git a/app/service/payment/order/RefundLifecycleService.php b/app/service/payment/order/RefundLifecycleService.php index 5da1c84..609a65f 100644 --- a/app/service/payment/order/RefundLifecycleService.php +++ b/app/service/payment/order/RefundLifecycleService.php @@ -3,6 +3,7 @@ namespace app\service\payment\order; use app\common\base\BaseService; +use app\common\constant\EventConstant; use app\common\constant\RouteConstant; use app\common\constant\TradeConstant; use app\exception\BusinessStateException; @@ -12,6 +13,7 @@ use app\repository\payment\trade\BizOrderRepository; use app\repository\payment\trade\PayOrderRepository; use app\repository\payment\trade\RefundOrderRepository; use app\service\account\funds\MerchantAccountService; +use Webman\Event\Event; /** * 退款单生命周期服务。 @@ -151,9 +153,17 @@ class RefundLifecycleService extends BaseService */ public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder { - return $this->transactionRetry(function () use ($refundNo, $input) { - return $this->markRefundSuccessInCurrentTransaction($refundNo, $input); + $shouldDispatchEvent = false; + + $refundOrder = $this->transactionRetry(function () use ($refundNo, $input, &$shouldDispatchEvent) { + return $this->markRefundSuccessInCurrentTransaction($refundNo, $input, $shouldDispatchEvent); }); + + if ($shouldDispatchEvent) { + $this->dispatchRefundOrderEvent(EventConstant::REFUND_ORDER_SUCCEEDED, $refundOrder); + } + + return $refundOrder; } /** @@ -165,7 +175,7 @@ class RefundLifecycleService extends BaseService * @throws ResourceNotFoundException * @throws BusinessStateException */ - public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder + public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = [], bool &$shouldDispatchEvent = false): RefundOrder { $refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo); if (!$refundOrder) { @@ -196,25 +206,23 @@ class RefundLifecycleService extends BaseService $traceNo = (string) ($refundOrder->trace_no ?: $refundOrder->biz_no); if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT) { - // 平台代收退款在已结算时,需要同步冲减商户可提现余额,口径按实收净额处理。 - $reverseAmount = max(0, (int) $payOrder->pay_amount - (int) $payOrder->fee_actual_amount); - if ((int) $payOrder->settlement_status === TradeConstant::SETTLEMENT_STATUS_SETTLED && $reverseAmount > 0) { - $this->merchantAccountService->debitAvailableAmountInCurrentTransaction( - (int) $refundOrder->merchant_id, - $reverseAmount, - (string) $refundOrder->refund_no, - 'REFUND_REVERSE:' . (string) $refundOrder->refund_no, - [ - 'pay_no' => (string) $refundOrder->pay_no, - 'remark' => '平台代收退款冲减', - ], - $traceNo - ); + if ((int) $payOrder->settlement_status === TradeConstant::SETTLEMENT_STATUS_SETTLED) { + // 平台代收退款在已结算时,需要同步冲减商户可提现余额,口径按本次退款净额处理。 + $reverseAmount = max(0, (int) $refundOrder->refund_amount - (int) $refundOrder->fee_reverse_amount); + if ($reverseAmount > 0) { + $this->merchantAccountService->debitAvailableAmountInCurrentTransaction( + (int) $refundOrder->merchant_id, + $reverseAmount, + (string) $refundOrder->refund_no, + 'REFUND_REVERSE:' . (string) $refundOrder->refund_no, + [ + 'pay_no' => (string) $refundOrder->pay_no, + 'remark' => '平台代收退款冲减', + ], + $traceNo + ); + } } - - // 已结算的代收单被退款后,状态要回写成 reversed,表示结算已被抵消。 - $payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_REVERSED; - $payOrder->save(); } // 退款成功后,退款单和业务单都要同步收口到成功态。 @@ -227,14 +235,19 @@ class RefundLifecycleService extends BaseService $bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $refundOrder->biz_no); if ($bizOrder) { - // 业务单的退款金额直接收口到原支付金额,避免后续展示和统计再做推导。 - $bizOrder->refund_amount = (int) $bizOrder->order_amount; + // 业务单的退款金额按累计值收口,支持多笔部分退款。 + $bizOrder->refund_amount = min( + (int) $bizOrder->order_amount, + (int) $bizOrder->refund_amount + (int) $refundOrder->refund_amount + ); if (empty($bizOrder->trace_no)) { $bizOrder->trace_no = $traceNo; } $bizOrder->save(); } + $shouldDispatchEvent = true; + return $refundOrder->refresh(); } @@ -247,9 +260,17 @@ class RefundLifecycleService extends BaseService */ public function markRefundFailed(string $refundNo, array $input = []): RefundOrder { - return $this->transactionRetry(function () use ($refundNo, $input) { - return $this->markRefundFailedInCurrentTransaction($refundNo, $input); + $shouldDispatchEvent = false; + + $refundOrder = $this->transactionRetry(function () use ($refundNo, $input, &$shouldDispatchEvent) { + return $this->markRefundFailedInCurrentTransaction($refundNo, $input, $shouldDispatchEvent); }); + + if ($shouldDispatchEvent) { + $this->dispatchRefundOrderEvent(EventConstant::REFUND_ORDER_FAILED, $refundOrder); + } + + return $refundOrder; } /** @@ -261,7 +282,7 @@ class RefundLifecycleService extends BaseService * @throws ResourceNotFoundException * @throws BusinessStateException */ - public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder + public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = [], bool &$shouldDispatchEvent = false): RefundOrder { $refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo); if (!$refundOrder) { @@ -297,7 +318,25 @@ class RefundLifecycleService extends BaseService } $refundOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []); $refundOrder->save(); + $shouldDispatchEvent = true; return $refundOrder->refresh(); } + + /** + * 发送退款单事件。 + * + * @param string $eventName 事件名称 + * @param RefundOrder $refundOrder 退款单 + * @return void + */ + private function dispatchRefundOrderEvent(string $eventName, RefundOrder $refundOrder): void + { + Event::dispatch($eventName, [ + 'refund_no' => (string) $refundOrder->refund_no, + 'pay_no' => (string) $refundOrder->pay_no, + 'biz_no' => (string) $refundOrder->biz_no, + 'refund_order' => $refundOrder, + ]); + } } diff --git a/app/service/payment/runtime/MerchantNotifyDispatcherService.php b/app/service/payment/runtime/MerchantNotifyDispatcherService.php new file mode 100644 index 0000000..80908aa --- /dev/null +++ b/app/service/payment/runtime/MerchantNotifyDispatcherService.php @@ -0,0 +1,525 @@ +httpClient = new Client([ + 'timeout' => 10, + 'connect_timeout' => 10, + 'verify' => true, + 'http_errors' => 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 + { + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $notifyUrl = trim((string) ($payOrder->notify_url ?: ($bizOrder?->notify_url ?? ''))); + if ($notifyUrl === '') { + return null; + } + + $task = $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, + ]); + + return $this->dispatchTask($task); + } + + /** + * 为退款成功创建通知任务,并立即尝试派发一次。 + * + * @param RefundOrder $refundOrder 退款单 + * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null + */ + public function enqueueAndDispatchRefundSuccess(RefundOrder $refundOrder): ?NotifyTask + { + $bizOrder = $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no); + $payOrder = $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no); + $notifyUrl = trim((string) ($payOrder?->notify_url ?: ($bizOrder?->notify_url ?? ''))); + if ($notifyUrl === '') { + return null; + } + + $task = $this->notifyService->enqueueMerchantNotify([ + 'merchant_id' => (int) $refundOrder->merchant_id, + 'merchant_group_id' => (int) $refundOrder->merchant_group_id, + 'event_type' => NotifyConstant::EVENT_REFUND_SUCCESS, + 'ref_no' => (string) $refundOrder->refund_no, + 'biz_no' => (string) $refundOrder->biz_no, + 'pay_no' => (string) $refundOrder->pay_no, + 'notify_url' => $notifyUrl, + 'notify_data' => $this->buildRefundSuccessPayload($refundOrder, $payOrder, $bizOrder), + 'status' => NotifyConstant::TASK_STATUS_PENDING, + ]); + + return $this->dispatchTask($task); + } + + /** + * 为清算完成创建通知任务,并立即尝试派发一次。 + * + * 当前清算单只有在 ext_json.notify_url 明确存在时才通知商户。 + * + * @param SettlementOrder $settlementOrder 清算单 + * @return NotifyTask|null 通知任务;没有 notify_url 时返回 null + */ + public function enqueueAndDispatchSettlementSuccess(SettlementOrder $settlementOrder): ?NotifyTask + { + $extJson = (array) ($settlementOrder->ext_json ?? []); + $notifyUrl = trim((string) ($extJson['notify_url'] ?? '')); + if ($notifyUrl === '') { + return null; + } + + $task = $this->notifyService->enqueueMerchantNotify([ + 'merchant_id' => (int) $settlementOrder->merchant_id, + 'merchant_group_id' => (int) $settlementOrder->merchant_group_id, + 'event_type' => NotifyConstant::EVENT_SETTLEMENT_SUCCESS, + 'ref_no' => (string) $settlementOrder->settle_no, + 'biz_no' => '', + 'pay_no' => '', + 'notify_url' => $notifyUrl, + 'notify_data' => $this->buildSettlementSuccessPayload($settlementOrder), + 'status' => NotifyConstant::TASK_STATUS_PENDING, + ]); + + return $this->dispatchTask($task); + } + + /** + * 派发单个通知任务。 + * + * @param NotifyTask|string $task 通知任务模型或通知号 + * @return NotifyTask + * @throws ResourceNotFoundException + */ + public function dispatchTask(NotifyTask|string $task): NotifyTask + { + $task = $this->resolveTask($task); + if ((int) $task->status === NotifyConstant::TASK_STATUS_SUCCESS) { + return $task; + } + + $eventName = EventConstant::MERCHANT_NOTIFY_FAILED; + try { + $response = $this->httpClient->request('GET', (string) $task->notify_url, [ + 'query' => (array) ($task->notify_data ?? []), + ]); + $body = trim((string) $response->getBody()); + + if (strtolower($body) === self::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 { + $task = $this->notifyService->markTaskFailed((string) $task->notify_no, [ + 'last_notify_at' => $this->now(), + 'last_response' => $this->truncateResponse($body !== '' ? $body : '商户未返回 success'), + ]); + } + } catch (Throwable $e) { + Log::warning(sprintf( + '[MerchantNotify] 派发失败 notify_no=%s pay_no=%s error=%s', + (string) $task->notify_no, + (string) $task->pay_no, + $e->getMessage() + )); + + $task = $this->notifyService->markTaskFailed((string) $task->notify_no, [ + 'last_notify_at' => $this->now(), + 'last_response' => $this->truncateResponse($e->getMessage()), + ]); + } + + $this->dispatchNotifyTaskEvent($eventName, $task); + + return $task; + } + + /** + * 批量重试到期任务。 + * + * @param int $limit 最大处理数量 + * @return int 实际处理数量 + */ + public function dispatchRetryableTasks(int $limit = 100): int + { + $limit = max(1, $limit); + $count = 0; + + foreach ($this->notifyService->listRetryableTasks() as $task) { + if ($count >= $limit) { + break; + } + + $this->dispatchTask($task); + $count++; + } + + return $count; + } + + /** + * 构建支付成功通知参数。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array + * @throws ValidationException + */ + 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), + default => throw new ValidationException('订单未记录协议版本,无法发送商户通知'), + }; + } + + /** + * 构建退款成功通知参数。 + * + * @param RefundOrder $refundOrder 退款单 + * @param PayOrder|null $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array + */ + private function buildRefundSuccessPayload(RefundOrder $refundOrder, ?PayOrder $payOrder = null, ?BizOrder $bizOrder = null): array + { + $payOrder ??= $this->payOrderRepository->findByPayNo((string) $refundOrder->pay_no); + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $refundOrder->biz_no); + $payload = $payOrder + ? $this->buildBasePaySuccessPayload($payOrder, $bizOrder) + : [ + 'pid' => (int) $refundOrder->merchant_id, + 'trade_no' => (string) $refundOrder->pay_no, + 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''), + 'money' => FormatHelper::amount((int) $refundOrder->refund_amount), + ]; + + $payload['trade_status'] = NotifyConstant::EVENT_REFUND_SUCCESS; + $payload['refund_no'] = (string) $refundOrder->refund_no; + $payload['out_refund_no'] = (string) $refundOrder->merchant_refund_no; + $payload['refundmoney'] = FormatHelper::amount((int) $refundOrder->refund_amount); + $payload['reducemoney'] = FormatHelper::amount((int) ($bizOrder?->refund_amount ?? $refundOrder->refund_amount)); + $payload['endtime'] = FormatHelper::dateTime($refundOrder->succeeded_at ?: $this->now()); + + return $this->signEventPayload($payload, $this->resolveProtocolVersion($payOrder, $bizOrder), (int) $refundOrder->merchant_id); + } + + /** + * 构建清算完成通知参数。 + * + * @param SettlementOrder $settlementOrder 清算单 + * @return array + */ + private function buildSettlementSuccessPayload(SettlementOrder $settlementOrder): array + { + $payload = [ + 'pid' => (int) $settlementOrder->merchant_id, + 'trade_status' => NotifyConstant::EVENT_SETTLEMENT_SUCCESS, + 'settle_no' => (string) $settlementOrder->settle_no, + 'cycle_type' => (int) $settlementOrder->cycle_type, + 'cycle_key' => (string) $settlementOrder->cycle_key, + 'money' => FormatHelper::amount((int) $settlementOrder->accounted_amount), + 'gross_money' => FormatHelper::amount((int) $settlementOrder->gross_amount), + 'fee_money' => FormatHelper::amount((int) $settlementOrder->fee_amount), + 'endtime' => FormatHelper::dateTime($settlementOrder->completed_at ?: $this->now()), + ]; + $extJson = (array) ($settlementOrder->ext_json ?? []); + $protocol = strtolower(trim((string) ($extJson['_protocol_version'] ?? self::PROTOCOL_V2))); + + return $this->signEventPayload($payload, $protocol, (int) $settlementOrder->merchant_id); + } + + /** + * 解析协议版本。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return string + */ + private function resolveProtocolVersion(?PayOrder $payOrder = null, ?BizOrder $bizOrder = null): string + { + $payExtJson = (array) (($payOrder?->ext_json) ?? []); + $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 : ''; + } + + /** + * 构建 V1 成功通知。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array + * @throws ValidationException + */ + private function buildV1PaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array + { + $credential = $this->merchantApiCredentialRepository->findByMerchantId((int) $payOrder->merchant_id); + $apiKey = trim((string) ($credential?->api_key ?? '')); + if ($apiKey === '') { + throw new ValidationException('商户 API Key 未配置,无法发送 V1 通知'); + } + + $payload = $this->buildBasePaySuccessPayload($payOrder, $bizOrder); + $payload['trade_status'] = 'TRADE_SUCCESS'; + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_MD5; + $payload['sign'] = $this->signerManager->sign($this->signPayload($payload), AuthConstant::API_SIGN_NAME_MD5, $apiKey); + + return $payload; + } + + /** + * 构建 V2 成功通知。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array + * @throws ValidationException + */ + private function buildV2PaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array + { + $privateKey = trim((string) config('epay.v2.platform_private_key', '')); + if ($privateKey === '') { + throw new ValidationException('平台 RSA 私钥未配置,无法发送 V2 通知'); + } + + $signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA); + $payload = $this->buildBasePaySuccessPayload($payOrder, $bizOrder); + $payload['trade_status'] = 'TRADE_SUCCESS'; + $payload['addtime'] = FormatHelper::dateTime($payOrder->created_at); + $payload['endtime'] = FormatHelper::dateTime($payOrder->paid_at ?: $this->now()); + $payload['timestamp'] = (string) time(); + $payload['sign_type'] = $signType; + $payload['sign'] = $this->signerManager->sign($this->signPayload($payload), $signType, $privateKey); + + return $payload; + } + + /** + * 按协议签名事件通知参数。 + * + * @param array $payload 通知参数 + * @param string $protocol 协议版本 + * @param int $merchantId 商户ID + * @return array + */ + private function signEventPayload(array $payload, string $protocol, int $merchantId): array + { + if ($protocol === self::PROTOCOL_V1) { + $credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId); + $apiKey = trim((string) ($credential?->api_key ?? '')); + if ($apiKey === '') { + throw new ValidationException('商户 API Key 未配置,无法发送 V1 通知'); + } + + $payload['sign_type'] = AuthConstant::API_SIGN_NAME_MD5; + $payload['sign'] = $this->signerManager->sign($this->signPayload($payload), AuthConstant::API_SIGN_NAME_MD5, $apiKey); + + return $payload; + } + + $privateKey = trim((string) config('epay.v2.platform_private_key', '')); + if ($privateKey === '') { + throw new ValidationException('平台 RSA 私钥未配置,无法发送 V2 通知'); + } + + $signType = (string) config('epay.v2.sign_type', AuthConstant::API_SIGN_NAME_SHA256_WITH_RSA); + $payload['timestamp'] = (string) time(); + $payload['sign_type'] = $signType; + $payload['sign'] = $this->signerManager->sign($this->signPayload($payload), $signType, $privateKey); + + return $payload; + } + + /** + * 构建 V1/V2 共用通知参数。 + * + * @param PayOrder $payOrder 支付单 + * @param BizOrder|null $bizOrder 业务单 + * @return array + */ + private function buildBasePaySuccessPayload(PayOrder $payOrder, ?BizOrder $bizOrder = null): array + { + $bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no); + $bizExtJson = (array) (($bizOrder?->ext_json) ?? []); + $merchantExt = (array) ($bizExtJson['merchant'] ?? []); + + $payload = [ + 'pid' => (int) $payOrder->merchant_id, + 'trade_no' => (string) $payOrder->pay_no, + 'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? ''), + 'type' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id), + 'name' => (string) ($bizOrder?->subject ?? ''), + 'money' => FormatHelper::amount((int) $payOrder->pay_amount), + ]; + + $param = $this->stringifyValue($merchantExt['param'] ?? ''); + if ($param !== '') { + $payload['param'] = $param; + } + + $buyer = $this->stringifyValue($merchantExt['buyer'] ?? ''); + if ($buyer !== '') { + $payload['buyer'] = $buyer; + } + + return $payload; + } + + /** + * 规整待签名参数。 + * + * @param array $payload 原始参数 + * @return array + */ + private function signPayload(array $payload): array + { + $params = $payload; + unset($params['sign'], $params['sign_type']); + + return $params; + } + + /** + * 解析通知任务。 + * + * @param NotifyTask|string $task 通知任务模型或通知号 + * @return NotifyTask + * @throws ResourceNotFoundException + */ + private function resolveTask(NotifyTask|string $task): NotifyTask + { + if ($task instanceof NotifyTask) { + return $task; + } + + $taskModel = $this->notifyTaskRepository->findByNotifyNo($task); + if (!$taskModel) { + throw new ResourceNotFoundException('通知任务不存在', ['notify_no' => $task]); + } + + return $taskModel; + } + + /** + * 发送商户通知任务事件。 + * + * @param string $eventName 事件名称 + * @param NotifyTask $task 通知任务 + * @return void + */ + private function dispatchNotifyTaskEvent(string $eventName, NotifyTask $task): void + { + Event::dispatch($eventName, [ + 'notify_no' => (string) $task->notify_no, + 'event_type' => (string) $task->event_type, + 'ref_no' => (string) $task->ref_no, + 'pay_no' => (string) ($task->pay_no ?? ''), + 'notify_task' => $task, + ]); + } + + /** + * 规范化任意值为字符串。 + * + * @param mixed $value 原始值 + * @return string + */ + private function stringifyValue(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_array($value) || is_object($value)) { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $json !== false ? $json : ''; + } + + return trim((string) $value); + } + + /** + * 截断响应内容,避免把超长 HTML 整段塞进日志字段。 + * + * @param string $body 响应体 + * @param int $length 最大长度 + * @return string + */ + private function truncateResponse(string $body, int $length = 1000): string + { + $body = trim($body); + if ($body === '') { + return ''; + } + + return mb_strlen($body) > $length ? mb_substr($body, 0, $length) : $body; + } +} diff --git a/app/service/payment/runtime/NotifyService.php b/app/service/payment/runtime/NotifyService.php index 57fb41c..a32b874 100644 --- a/app/service/payment/runtime/NotifyService.php +++ b/app/service/payment/runtime/NotifyService.php @@ -4,6 +4,8 @@ namespace app\service\payment\runtime; use app\common\base\BaseService; use app\common\constant\NotifyConstant; +use app\exception\ResourceNotFoundException; +use app\exception\ValidationException; use app\common\util\FormatHelper; use app\model\admin\ChannelNotifyLog; use app\model\payment\NotifyTask; @@ -11,6 +13,7 @@ use app\model\admin\PayCallbackLog; use app\repository\ops\log\ChannelNotifyLogRepository; use app\repository\payment\notify\NotifyTaskRepository; use app\repository\ops\log\PayCallbackLogRepository; +use app\service\system\config\SystemConfigRuntimeService; /** * 通知服务。 @@ -29,12 +32,14 @@ class NotifyService extends BaseService * @param ChannelNotifyLogRepository $channelNotifyLogRepository 渠道通知日志仓库 * @param PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库 * @param NotifyTaskRepository $notifyTaskRepository 通知任务仓库 + * @param SystemConfigRuntimeService $systemConfigRuntimeService 系统配置运行时服务 * @return void */ public function __construct( protected ChannelNotifyLogRepository $channelNotifyLogRepository, protected PayCallbackLogRepository $payCallbackLogRepository, - protected NotifyTaskRepository $notifyTaskRepository + protected NotifyTaskRepository $notifyTaskRepository, + protected SystemConfigRuntimeService $systemConfigRuntimeService ) { } @@ -45,7 +50,7 @@ class NotifyService extends BaseService * * @param array $input 通知数据 * @return ChannelNotifyLog 渠道通知日志 - * @throws InvalidArgumentException + * @throws ValidationException */ public function recordChannelNotify(array $input): ChannelNotifyLog { @@ -54,7 +59,10 @@ class NotifyService extends BaseService $bizNo = trim((string) ($input['biz_no'] ?? '')); if ($channelId <= 0 || $bizNo === '') { - throw new \InvalidArgumentException('渠道通知入参不完整'); + throw new ValidationException('渠道通知入参不完整', [ + 'channel_id' => $channelId, + 'biz_no' => $bizNo, + ]); } // 同一业务单如果已经记录过相同类型的通知,就直接复用旧日志,避免重复落库。 @@ -82,36 +90,33 @@ class NotifyService extends BaseService /** * 记录支付回调日志。 * - * 以支付单号 + 回调类型作为去重依据。 + * 渠道回调是排障证据,每次请求都要留痕;重复识别交给 request_hash, + * 不在日志层吞掉后续通知。 * * @param array $input 回调数据 * @return PayCallbackLog 支付回调日志 - * @throws InvalidArgumentException + * @throws ValidationException */ public function recordPayCallback(array $input): PayCallbackLog { $payNo = trim((string) ($input['pay_no'] ?? '')); if ($payNo === '') { - throw new \InvalidArgumentException('pay_no 不能为空'); + throw new ValidationException('pay_no 不能为空', ['pay_no' => $payNo]); } $callbackType = (int) ($input['callback_type'] ?? NotifyConstant::CALLBACK_TYPE_ASYNC); - $logs = $this->payCallbackLogRepository->listByPayNo($payNo); - foreach ($logs as $log) { - // 同一支付单的同一类型回调只保留一条,后续重复请求直接返回已有日志。 - if ((int) $log->callback_type === $callbackType) { - return $log; - } - } + $requestData = $input['request_data'] ?? []; return $this->payCallbackLogRepository->create([ 'pay_no' => $payNo, 'channel_id' => (int) ($input['channel_id'] ?? 0), 'callback_type' => $callbackType, - 'request_data' => $input['request_data'] ?? [], + 'request_data' => $requestData, + 'request_hash' => $this->payloadHash($requestData), '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'] ?? [], + 'created_at' => $input['created_at'] ?? $this->now(), ]); } @@ -125,8 +130,21 @@ class NotifyService extends BaseService */ public function enqueueMerchantNotify(array $input): NotifyTask { + $eventType = (string) ($input['event_type'] ?? NotifyConstant::EVENT_PAY_SUCCESS); + $refNo = (string) ($input['ref_no'] ?? $input['pay_no'] ?? ''); + if ($refNo === '') { + throw new ValidationException('通知事件引用单号不能为空'); + } + + $existing = $this->notifyTaskRepository->findByEventRef($eventType, $refNo); + if ($existing) { + return $existing; + } + return $this->notifyTaskRepository->create([ 'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('NTF')), + 'event_type' => $eventType, + 'ref_no' => $refNo, 'merchant_id' => (int) ($input['merchant_id'] ?? 0), 'merchant_group_id' => (int) ($input['merchant_group_id'] ?? 0), 'biz_no' => (string) ($input['biz_no'] ?? ''), @@ -149,13 +167,13 @@ class NotifyService extends BaseService * @param string $notifyNo 通知号 * @param array $input 附加数据 * @return NotifyTask 通知任务 - * @throws InvalidArgumentException + * @throws ResourceNotFoundException */ public function markTaskSuccess(string $notifyNo, array $input = []): NotifyTask { $task = $this->notifyTaskRepository->findByNotifyNo($notifyNo); if (!$task) { - throw new \InvalidArgumentException('通知任务不存在'); + throw new ResourceNotFoundException('通知任务不存在', ['notify_no' => $notifyNo]); } $task->status = NotifyConstant::TASK_STATUS_SUCCESS; @@ -174,13 +192,13 @@ class NotifyService extends BaseService * @param string $notifyNo 通知号 * @param array $input 附加数据 * @return NotifyTask 通知任务 - * @throws InvalidArgumentException + * @throws ResourceNotFoundException */ public function markTaskFailed(string $notifyNo, array $input = []): NotifyTask { $task = $this->notifyTaskRepository->findByNotifyNo($notifyNo); if (!$task) { - throw new \InvalidArgumentException('通知任务不存在'); + throw new ResourceNotFoundException('通知任务不存在', ['notify_no' => $notifyNo]); } // 每次失败都累计一次重试,并根据新的次数重新计算下一次触发时间。 @@ -189,7 +207,7 @@ class NotifyService extends BaseService $task->retry_count = $retryCount; $task->last_notify_at = $input['last_notify_at'] ?? $this->now(); $task->last_response = (string) ($input['last_response'] ?? ''); - $task->next_retry_at = $this->nextRetryAt($retryCount); + $task->next_retry_at = $retryCount >= $this->retryLimit() ? null : $this->nextRetryAt($retryCount); $task->save(); return $task->refresh(); @@ -216,15 +234,49 @@ class NotifyService extends BaseService private function nextRetryAt(int $retryCount): string { $retryCount = max(0, $retryCount); + $baseDelay = $this->retryIntervalMinutes() * 60; $delay = match (true) { $retryCount <= 0 => 60, - $retryCount === 1 => 300, - $retryCount === 2 => 900, - default => 1800, + $retryCount === 1 => $baseDelay, + $retryCount === 2 => $baseDelay * 3, + default => $baseDelay * 6, }; return FormatHelper::timestamp(time() + $delay); } + + /** + * 获取商户通知最大重试次数。 + * + * @return int 最大重试次数 + */ + private function retryLimit(): int + { + return max(1, (int) $this->systemConfigRuntimeService->get('pay_notify_retry_limit', 3)); + } + + /** + * 获取商户通知重试基础间隔。 + * + * @return int 基础间隔,单位分钟 + */ + private function retryIntervalMinutes(): int + { + return max(1, (int) $this->systemConfigRuntimeService->get('pay_notify_retry_interval', 10)); + } + + /** + * 生成稳定的载荷摘要,用于后台识别重复通知。 + * + * @param mixed $payload 原始载荷 + * @return string SHA-256 摘要 + */ + private function payloadHash(mixed $payload): string + { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); + + return hash('sha256', $json !== false ? $json : serialize($payload)); + } } diff --git a/app/service/payment/runtime/PaymentPluginFactoryService.php b/app/service/payment/runtime/PaymentPluginFactoryService.php index fecd982..ae548e6 100644 --- a/app/service/payment/runtime/PaymentPluginFactoryService.php +++ b/app/service/payment/runtime/PaymentPluginFactoryService.php @@ -3,6 +3,7 @@ namespace app\service\payment\runtime; use app\common\base\BaseService; +use app\common\constant\CommonConstant; use app\common\interface\PaymentInterface; use app\common\interface\PayPluginInterface; use app\exception\PaymentException; @@ -243,7 +244,7 @@ class PaymentPluginFactoryService extends BaseService throw new PaymentException('支付插件不存在', 40401, ['plugin_code' => $pluginCode]); } - if (!$allowDisabled && (int) $plugin->status !== 1) { + if (!$allowDisabled && (int) $plugin->status !== CommonConstant::STATUS_ENABLED) { throw new PaymentException('支付插件已禁用', 40214, ['plugin_code' => $pluginCode]); } diff --git a/app/service/payment/runtime/PaymentRouteResolverService.php b/app/service/payment/runtime/PaymentRouteResolverService.php index dbe3609..30950cf 100644 --- a/app/service/payment/runtime/PaymentRouteResolverService.php +++ b/app/service/payment/runtime/PaymentRouteResolverService.php @@ -9,6 +9,7 @@ use app\common\util\FormatHelper; use app\exception\BusinessStateException; use app\exception\ResourceNotFoundException; use app\exception\ValidationException; +use Throwable; use app\model\payment\PaymentChannel; use app\model\payment\PaymentPollGroup; use app\repository\ops\stat\ChannelDailyStatRepository; @@ -79,6 +80,7 @@ class PaymentRouteResolverService extends BaseService throw new ValidationException('路由参数不合法'); } + // 先锁定商户分组与支付方式的绑定,再进入正式通道选路。 $bind = $this->bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId); if (!$bind) { throw new ResourceNotFoundException('路由不存在', [ @@ -87,13 +89,107 @@ class PaymentRouteResolverService extends BaseService ]); } + $route = $this->resolveRouteSelection( + $merchantGroupId, + (int) $bind->poll_group_id, + $payTypeId, + $payAmount, + $context + ); + $route['bind'] = $bind; + + return $route; + } + + /** + * 预览商户可用支付方式。 + * + * 这里会遍历所有启用中的支付方式,并复用正式路由解析逻辑筛出真正可用的方式。 + * + * @param int $merchantGroupId 商户分组ID + * @param int $payAmount 支付金额(分) + * @param array $context 路由上下文 + * @return array> 可用支付方式列表 + */ + public function previewAvailablePayTypes(int $merchantGroupId, int $payAmount, array $context = []): array + { + if ($merchantGroupId <= 0 || $payAmount <= 0) { + return []; + } + + // 预览阶段只拿绑定摘要,不先把所有通道明细一次性拉出来。 + $bindRows = $this->bindRepository->listSummaryByMerchantGroupId($merchantGroupId); + if ($bindRows->isEmpty()) { + return []; + } + + $available = []; + foreach ($bindRows as $row) { + if ((int) ($row->status ?? 0) !== CommonConstant::STATUS_ENABLED) { + continue; + } + + try { + // 每个可用支付方式仍然复用正式选路逻辑,只是最终结果被压成前端可展示摘要。 + $route = $this->resolveRouteSelection( + $merchantGroupId, + (int) ($row->poll_group_id ?? 0), + (int) ($row->pay_type_id ?? 0), + $payAmount, + $context + ); + } catch (Throwable) { + continue; + } + + $selected = $route['selected_channel']; + /** @var PaymentChannel $channel */ + $channel = $selected['channel']; + $available[] = [ + 'pay_type_id' => (int) ($row->pay_type_id ?? 0), + 'code' => (string) ($row->pay_type_code ?? ''), + 'name' => (string) ($row->pay_type_name ?? ''), + 'selected_channel_id' => (int) $channel->id, + 'selected_channel_name' => (string) $channel->name, + 'selected_channel_mode' => (int) $channel->channel_mode, + ]; + } + + return $available; + } + + /** + * 解析指定轮询组与支付方式的可用通道路由。 + * + * 该方法负责轮询组、候选通道、插件和统计数据的加载与过滤,并返回排序后的候选集和最终选中通道。 + * + * @param int $merchantGroupId 商户分组ID + * @param int $pollGroupId 轮询组ID + * @param int $payTypeId 支付类型ID + * @param int $payAmount 支付金额(分) + * @param array $context 路由上下文,支持传入 `stat_date` 等辅助参数 + * @return array{ + * poll_group: PaymentPollGroup, + * candidates: array>, + * selected_channel: array + * } + * @throws ValidationException + * @throws ResourceNotFoundException + * @throws BusinessStateException + */ + private function resolveRouteSelection(int $merchantGroupId, int $pollGroupId, int $payTypeId, int $payAmount, array $context = []): array + { + if ($merchantGroupId <= 0 || $pollGroupId <= 0 || $payTypeId <= 0 || $payAmount <= 0) { + throw new ValidationException('路由参数不合法'); + } + /** @var PaymentPollGroup|null $pollGroup */ - $pollGroup = $this->pollGroupRepository->find((int) $bind->poll_group_id); + $pollGroup = $this->pollGroupRepository->find($pollGroupId); if (!$pollGroup || (int) $pollGroup->status !== CommonConstant::STATUS_ENABLED) { throw new ResourceNotFoundException('路由不存在', [ 'merchant_group_id' => $merchantGroupId, 'pay_type_id' => $payTypeId, - 'poll_group_id' => (int) ($bind->poll_group_id ?? 0), + 'poll_group_id' => $pollGroupId, ]); } @@ -104,9 +200,8 @@ class PaymentRouteResolverService extends BaseService ]); } - // 先拿到轮询组下的编排记录,再去批量加载通道、插件和统计数据,避免逐条查库。 + // 先把轮询组里的候选通道、插件和渠道统计一次性取齐,再做逐层过滤。 $channelIds = $candidateRows->pluck('channel_id')->all(); - // 先一次性拉出通道和插件信息,避免候选过滤过程中频繁查库。 $channels = $this->channelRepository->query() ->whereIn('id', $channelIds) ->where('status', CommonConstant::STATUS_ENABLED) @@ -115,7 +210,6 @@ class PaymentRouteResolverService extends BaseService $pluginCodes = $channels->pluck('plugin_code')->filter()->unique()->values()->all(); $plugins = []; if (!empty($pluginCodes)) { - // 通道会复用同一个插件实现,插件信息也按编码批量加载一次即可。 $plugins = $this->paymentPluginRepository->query() ->whereIn('code', $pluginCodes) ->get() @@ -123,9 +217,10 @@ class PaymentRouteResolverService extends BaseService ->all(); } $paymentType = $this->paymentTypeRepository->find($payTypeId); + if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) { + throw new ValidationException('支付方式不支持'); + } $payTypeCode = trim((string) ($paymentType->code ?? '')); - - // 默认统计日期取当天,路由预览时也可以由外部显式传入历史日期。 $statDate = $context['stat_date'] ?? FormatHelper::timestamp(time(), 'Y-m-d'); $payAmount = (int) $payAmount; $eligible = []; @@ -139,7 +234,7 @@ class PaymentRouteResolverService extends BaseService continue; } - // 先按支付方式收口,避免插件和通道配置不一致时误选。 + // 通道类型、插件支持、金额区间和日限额都必须同时满足,候选才算有效。 if ((int) $channel->pay_type_id !== $payTypeId) { continue; } @@ -150,25 +245,21 @@ class PaymentRouteResolverService extends BaseService continue; } - // 通道还必须被插件明确支持,才允许进入候选集。 $pluginPayTypes = is_array($plugin->pay_types) ? $plugin->pay_types : []; $pluginPayTypes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $pluginPayTypes))); if ($payTypeCode === '' || !in_array($payTypeCode, $pluginPayTypes, true)) { continue; } - // 金额区间不匹配的通道直接过滤掉。 if (!$this->isAmountAllowed($channel, $payAmount)) { continue; } - // 日限额和日成功笔数也要同时校验,防止选中已接近上限的通道。 $stat = $this->channelDailyStatRepository->findByChannelAndDate($channelId, $statDate); if (!$this->isDailyLimitAllowed($channel, $payAmount, $statDate, $stat)) { continue; } - // 保留排序和择优所需的权重、默认标记和统计指标。 $eligible[] = [ 'channel' => $channel, 'poll_group_channel' => $row, @@ -183,7 +274,6 @@ class PaymentRouteResolverService extends BaseService } if (empty($eligible)) { - // 所有候选都被过滤后,直接判定通道不可用。 throw new BusinessStateException('支付通道不可用', [ 'poll_group_id' => (int) $pollGroup->id, 'merchant_group_id' => $merchantGroupId, @@ -191,14 +281,12 @@ class PaymentRouteResolverService extends BaseService ]); } - // 按路由模式进行排序,然后再选出最终通道。 $routeMode = (int) $pollGroup->route_mode; + // 剩余候选再按轮询组策略排序,最终只从排序结果里挑一条。 $ordered = $this->sortCandidates($eligible, $routeMode); $selected = $this->selectChannel($ordered, $routeMode, (int) $pollGroup->id); - // 返回绑定、轮询组、候选集和最终选中项,供路由预览和实际支付共用。 return [ - 'bind' => $bind, 'poll_group' => $pollGroup, 'candidates' => $ordered, 'selected_channel' => $selected, diff --git a/app/service/payment/runtime/PaymentRouteService.php b/app/service/payment/runtime/PaymentRouteService.php index cf4a321..4ef6956 100644 --- a/app/service/payment/runtime/PaymentRouteService.php +++ b/app/service/payment/runtime/PaymentRouteService.php @@ -35,7 +35,19 @@ class PaymentRouteService extends BaseService { return $this->resolverService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $context); } + + /** + * 预览商户可用支付方式。 + * + * @param int $merchantGroupId 商户分组ID + * @param int $payAmount 支付金额(分) + * @param array $context 路由上下文 + * @return array> 可用支付方式列表 + */ + public function previewAvailablePayTypes(int $merchantGroupId, int $payAmount, array $context = []): array + { + return $this->resolverService->previewAvailablePayTypes($merchantGroupId, $payAmount, $context); + } } - diff --git a/app/service/payment/runtime/PaymentRuntimeMaintenanceService.php b/app/service/payment/runtime/PaymentRuntimeMaintenanceService.php new file mode 100644 index 0000000..eb6f9d4 --- /dev/null +++ b/app/service/payment/runtime/PaymentRuntimeMaintenanceService.php @@ -0,0 +1,346 @@ + 执行摘要 + */ + public function retryMerchantNotifies(int $limit = 100): array + { + return [ + 'dispatched' => $this->merchantNotifyDispatcherService->dispatchRetryableTasks($limit), + ]; + } + + /** + * 将已过期的非终态支付单推进为超时。 + * + * @param int $limit 批量数量 + * @return array 执行摘要 + */ + public function timeoutExpiredPayOrders(int $limit = 100): array + { + $summary = [ + 'scanned' => 0, + 'timeout' => 0, + 'skipped' => 0, + 'failed' => 0, + ]; + + foreach ($this->payOrderRepository->listExpiredMutable($this->now(), $limit) as $payOrder) { + $summary['scanned']++; + + try { + $this->payOrderLifecycleService->timeoutPayOrder((string) $payOrder->pay_no, [ + 'reason' => '系统定时任务检测到支付单已过期', + 'ext_json' => [ + 'lifecycle' => [ + 'timeout_source' => 'runtime_process', + ], + ], + ]); + $summary['timeout']++; + } catch (\Throwable $e) { + $summary['failed']++; + Log::warning(sprintf( + '[PaymentRuntimeMaintenance] 支付单超时处理失败 pay_no=%s error=%s', + (string) $payOrder->pay_no, + $e->getMessage() + )); + } + } + + return $summary; + } + + /** + * 主动查询支付中订单并按上游结果推进状态。 + * + * @param int $limit 批量数量 + * @param int $minAgeSeconds 支付拉起后至少等待秒数 + * @return array 执行摘要 + */ + public function syncPayingOrdersByQuery(int $limit = 50, int $minAgeSeconds = 60): array + { + $before = date('Y-m-d H:i:s', time() - max(1, $minAgeSeconds)); + $summary = [ + 'scanned' => 0, + 'success' => 0, + 'failed' => 0, + 'closed' => 0, + 'pending' => 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'); + $summary['error']++; + } + } + + return $summary; + } + + /** + * 构建插件查单参数。 + * + * @param PayOrder $payOrder 支付单 + * @return array 查单参数 + */ + private function buildQueryOrder(PayOrder $payOrder): array + { + return [ + 'pay_no' => (string) $payOrder->pay_no, + 'order_id' => (string) $payOrder->pay_no, + 'out_trade_no' => (string) $payOrder->pay_no, + 'biz_no' => (string) $payOrder->biz_no, + 'trace_no' => (string) $payOrder->trace_no, + 'chan_order_no' => (string) ($payOrder->channel_order_no ?? ''), + 'chan_trade_no' => (string) ($payOrder->channel_trade_no ?? ''), + 'channel_order_no' => (string) ($payOrder->channel_order_no ?? ''), + 'channel_trade_no' => (string) ($payOrder->channel_trade_no ?? ''), + 'pay_type_id' => (int) $payOrder->pay_type_id, + 'pay_type_code' => $this->paymentTypeService->resolveCodeById((int) $payOrder->pay_type_id), + 'amount' => (int) $payOrder->pay_amount, + 'pay_amount' => (int) $payOrder->pay_amount, + 'client_ip' => (string) ($payOrder->client_ip ?? ''), + '_env' => (string) (($payOrder->device ?? '') ?: 'pc'), + 'extra' => (array) ($payOrder->ext_json ?? []), + ]; + } + + /** + * 归一化插件查单结果。 + * + * @param PayOrder $payOrder 支付单 + * @param array $result 插件查单结果 + * @return array 归一化结果 + */ + private function normalizeQueryResult(PayOrder $payOrder, array $result): array + { + $statusText = strtolower(trim((string) ($result['status'] ?? $result['trade_status'] ?? $result['channel_status'] ?? ''))); + $success = array_key_exists('success', $result) ? (bool) $result['success'] : null; + + $status = match (true) { + in_array($statusText, PaymentPluginStatusConstant::successQueryAliases(), true) => PaymentPluginStatusConstant::SUCCESS, + in_array($statusText, PaymentPluginStatusConstant::closedQueryAliases(), true) => PaymentPluginStatusConstant::CLOSED, + in_array($statusText, PaymentPluginStatusConstant::failedQueryAliases(), true) => PaymentPluginStatusConstant::FAILED, + $success === false => PaymentPluginStatusConstant::UNKNOWN, + default => PaymentPluginStatusConstant::PENDING, + }; + + $channelOrderNo = $this->firstText($result, ['channel_order_no', 'chan_order_no', 'out_trade_no']); + $channelTradeNo = $this->firstText($result, ['channel_trade_no', 'chan_trade_no', 'trade_no', 'api_trade_no']); + $channelStatus = trim((string) ($result['channel_status'] ?? $result['status'] ?? '')); + $message = $this->firstText($result, ['message', 'msg', 'channel_error_msg']); + + return [ + 'status' => $status, + 'raw_status' => $statusText, + 'channel_order_no' => $channelOrderNo !== '' ? $channelOrderNo : (string) ($payOrder->channel_order_no ?? ''), + 'channel_trade_no' => $channelTradeNo !== '' ? $channelTradeNo : (string) ($payOrder->channel_trade_no ?? ''), + 'channel_status' => $channelStatus, + 'channel_error_code' => $this->firstText($result, ['channel_error_code', 'code']), + 'channel_error_msg' => $message !== '' ? $message : ($status === PaymentPluginStatusConstant::FAILED ? '主动查单返回支付失败' : ''), + 'paid_at' => $result['paid_at'] ?? null, + 'failed_at' => $result['failed_at'] ?? null, + ]; + } + + /** + * 构建支付单内保存的轻量查单快照。 + * + * @param array $normalized 归一化结果 + * @param array $result 插件原始结果 + * @return array 快照 + */ + private function buildQuerySnapshot(array $normalized, array $result): array + { + return [ + 'queried_at' => $this->now(), + 'status' => (string) $normalized['status'], + 'raw_status' => (string) ($normalized['raw_status'] ?? ''), + 'channel_status' => (string) ($normalized['channel_status'] ?? ''), + 'message' => $this->firstText($result, ['message', 'msg']), + 'success' => array_key_exists('success', $result) ? (bool) $result['success'] : null, + 'channel_order_no' => (string) ($normalized['channel_order_no'] ?? ''), + 'channel_trade_no' => (string) ($normalized['channel_trade_no'] ?? ''), + ]; + } + + /** + * 记录支付中订单的查单快照。 + * + * @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 + */ + private function recordQueryError(PayOrder $payOrder, string $message, string $code): void + { + Log::warning(sprintf( + '[PaymentRuntimeMaintenance] 主动查单失败 pay_no=%s code=%s error=%s', + (string) $payOrder->pay_no, + $code, + $message + )); + + $this->recordQuerySnapshot($payOrder, [ + 'queried_at' => $this->now(), + 'status' => 'error', + 'raw_status' => '', + 'channel_status' => '', + 'message' => $message, + 'success' => false, + 'error_code' => $code, + 'channel_order_no' => (string) ($payOrder->channel_order_no ?? ''), + 'channel_trade_no' => (string) ($payOrder->channel_trade_no ?? ''), + ]); + } + + /** + * 从候选字段中取首个非空文本。 + * + * @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)) { + $text = trim((string) $value); + if ($text !== '') { + return $text; + } + } + } + + return ''; + } +} diff --git a/app/service/payment/settlement/SettlementLifecycleService.php b/app/service/payment/settlement/SettlementLifecycleService.php index 8bef5b1..2d15ef5 100644 --- a/app/service/payment/settlement/SettlementLifecycleService.php +++ b/app/service/payment/settlement/SettlementLifecycleService.php @@ -3,6 +3,7 @@ namespace app\service\payment\settlement; use app\common\base\BaseService; +use app\common\constant\EventConstant; use app\common\constant\TradeConstant; use app\exception\BusinessStateException; use app\exception\ResourceNotFoundException; @@ -12,6 +13,7 @@ use app\repository\payment\settlement\SettlementItemRepository; use app\repository\payment\settlement\SettlementOrderRepository; use app\repository\payment\trade\PayOrderRepository; use app\service\account\funds\MerchantAccountService; +use Webman\Event\Event; /** * 清算生命周期服务。 @@ -133,7 +135,9 @@ class SettlementLifecycleService extends BaseService */ public function completeSettlement(string $settleNo): SettlementOrder { - return $this->transactionRetry(function () use ($settleNo) { + $shouldDispatchEvent = false; + + $settlementOrder = $this->transactionRetry(function () use ($settleNo, &$shouldDispatchEvent) { $settlementOrder = $this->settlementOrderRepository->findForUpdateBySettleNo($settleNo); if (!$settlementOrder) { throw new ResourceNotFoundException('清结算单不存在', ['settle_no' => $settleNo]); @@ -191,8 +195,16 @@ class SettlementLifecycleService extends BaseService } } + $shouldDispatchEvent = true; + return $settlementOrder->refresh(); }); + + if ($shouldDispatchEvent) { + $this->dispatchSettlementOrderEvent(EventConstant::SETTLEMENT_ORDER_SUCCEEDED, $settlementOrder); + } + + return $settlementOrder; } /** @@ -208,7 +220,9 @@ class SettlementLifecycleService extends BaseService */ public function failSettlement(string $settleNo, string $reason = ''): SettlementOrder { - return $this->transactionRetry(function () use ($settleNo, $reason) { + $shouldDispatchEvent = false; + + $settlementOrder = $this->transactionRetry(function () use ($settleNo, $reason, &$shouldDispatchEvent) { $settlementOrder = $this->settlementOrderRepository->findForUpdateBySettleNo($settleNo); if (!$settlementOrder) { throw new ResourceNotFoundException('清结算单不存在', ['settle_no' => $settleNo]); @@ -248,8 +262,31 @@ class SettlementLifecycleService extends BaseService $item->save(); } + $shouldDispatchEvent = true; + return $settlementOrder->refresh(); }); + + if ($shouldDispatchEvent) { + $this->dispatchSettlementOrderEvent(EventConstant::SETTLEMENT_ORDER_FAILED, $settlementOrder); + } + + return $settlementOrder; + } + + /** + * 发送清算单事件。 + * + * @param string $eventName 事件名称 + * @param SettlementOrder $settlementOrder 清算单 + * @return void + */ + private function dispatchSettlementOrderEvent(string $eventName, SettlementOrder $settlementOrder): void + { + Event::dispatch($eventName, [ + 'settle_no' => (string) $settlementOrder->settle_no, + 'settlement_order' => $settlementOrder, + ]); } /** diff --git a/app/service/payment/transfer/TransferService.php b/app/service/payment/transfer/TransferService.php new file mode 100644 index 0000000..0f7ef8d --- /dev/null +++ b/app/service/payment/transfer/TransferService.php @@ -0,0 +1,214 @@ + + */ + public function submit(Merchant $merchant, array $input): array + { + $type = trim((string) ($input['type'] ?? '')); + $account = trim((string) ($input['account'] ?? '')); + $name = trim((string) ($input['name'] ?? '')); + $money = trim((string) ($input['money'] ?? '')); + $amount = $this->parseMoneyToAmount($money); + + if ($type === '') { + throw new ValidationException('type 不能为空'); + } + if ($account === '' || $name === '') { + throw new ValidationException('account/name 不能为空'); + } + if ($amount <= 0) { + throw new ValidationException('money 参数不合法'); + } + + $merchantId = (int) $merchant->id; + $outBizNo = trim((string) ($input['out_biz_no'] ?? '')); + if ($outBizNo !== '') { + $existing = $this->transferOrderRepository->findByOutBizNo($merchantId, $outBizNo); + if ($existing) { + if ((int) $existing->amount !== $amount) { + throw new ConflictException('幂等冲突', [ + 'biz_no' => (string) $existing->biz_no, + 'out_biz_no' => $outBizNo, + ]); + } + + return $this->formatTransferOrder($existing); + } + } + + $transferRate = $this->resolveTransferRate(); + $costAmount = (int) floor($amount * $transferRate); + $bizNo = $this->generateNo('TRF'); + $traceNo = $this->generateNo('TRC'); + + /** @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'] ?? []), + ]); + + return $this->formatTransferOrder($transferOrder); + } + + /** + * 查询转账单。 + * + * @param Merchant $merchant 商户 + * @param array $input 请求参数 + * @return array + */ + public function query(Merchant $merchant, array $input): array + { + $order = $this->resolveTransferOrder($merchant, $input); + return $this->formatTransferOrder($order); + } + + /** + * 查询转账余额。 + * + * @param Merchant $merchant 商户 + * @return array + */ + public function balance(Merchant $merchant): array + { + $account = $this->merchantAccountRepository->findByMerchantId((int) $merchant->id); + return [ + 'available_money' => $this->formatAmount((int) ($account->available_balance ?? 0)), + 'transfer_rate' => number_format($this->resolveTransferRate(), 2, '.', ''), + ]; + } + + /** + * 解析转账单。 + * + * @param Merchant $merchant 商户 + * @param array $input 请求参数 + * @return TransferOrder + */ + private function resolveTransferOrder(Merchant $merchant, array $input): TransferOrder + { + $merchantId = (int) $merchant->id; + $bizNo = trim((string) ($input['biz_no'] ?? '')); + $outBizNo = trim((string) ($input['out_biz_no'] ?? '')); + + if ($bizNo !== '') { + $order = $this->transferOrderRepository->findByBizNo($bizNo); + if (!$order || (int) $order->merchant_id !== $merchantId) { + throw new ResourceNotFoundException('转账单不存在', ['biz_no' => $bizNo]); + } + + return $order; + } + + if ($outBizNo !== '') { + $order = $this->transferOrderRepository->findByOutBizNo($merchantId, $outBizNo); + if (!$order) { + throw new ResourceNotFoundException('转账单不存在', ['out_biz_no' => $outBizNo]); + } + + return $order; + } + + throw new ValidationException('biz_no/out_biz_no 不能为空'); + } + + /** + * 格式化转账单。 + * + * @param TransferOrder $order 转账单 + * @return array + */ + private function formatTransferOrder(TransferOrder $order): array + { + return [ + 'status' => (int) $order->status, + '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, + 'paydate' => $this->formatDateTime($order->succeeded_at ?? null, ''), + 'amount' => $this->formatAmount((int) $order->amount), + 'cost_money' => $this->formatAmount((int) $order->cost_amount), + 'remark' => (string) $order->remark, + ]; + } + + /** + * 解析转账费率。 + * + * @return float + */ + private function resolveTransferRate(): float + { + $rate = (string) config('epay.v2.transfer_rate', '0.01'); + $rate = trim($rate); + if ($rate === '' || !is_numeric($rate)) { + return 0.01; + } + + $floatRate = (float) $rate; + return $floatRate > 0 ? $floatRate : 0.01; + } + + /** + * 金额字符串转分。 + * + * @param string $money 金额字符串 + * @return int + */ + 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); + } +} diff --git a/app/service/system/access/AdminAuthService.php b/app/service/system/access/AdminAuthService.php index b19f6c4..128c57c 100644 --- a/app/service/system/access/AdminAuthService.php +++ b/app/service/system/access/AdminAuthService.php @@ -43,7 +43,7 @@ class AdminAuthService extends BaseService */ public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?AdminUser { - $result = $this->jwtTokenManager->verify('admin', $token, $ip, $userAgent); + $result = $this->jwtTokenManager->verify(AuthConstant::GUARD_ADMIN, $token, $ip, $userAgent); if ($result === null) { return null; } @@ -98,7 +98,7 @@ class AdminAuthService extends BaseService */ public function revokeToken(string $token): bool { - return $this->jwtTokenManager->revoke('admin', $token); + return $this->jwtTokenManager->revoke(AuthConstant::GUARD_ADMIN, $token); } /** @@ -119,7 +119,7 @@ class AdminAuthService extends BaseService throw new ValidationException('管理员不存在'); } - $issued = $this->jwtTokenManager->issue('admin', [ + $issued = $this->jwtTokenManager->issue(AuthConstant::GUARD_ADMIN, [ 'sub' => (string) $adminId, 'admin_id' => $adminId, 'username' => (string) $admin->username, @@ -143,4 +143,3 @@ class AdminAuthService extends BaseService - diff --git a/app/service/system/config/SystemConfigDefinitionService.php b/app/service/system/config/SystemConfigDefinitionService.php index 4b755fe..b2b93d4 100644 --- a/app/service/system/config/SystemConfigDefinitionService.php +++ b/app/service/system/config/SystemConfigDefinitionService.php @@ -3,7 +3,7 @@ namespace app\service\system\config; use app\common\base\BaseService; -use RuntimeException; +use app\exception\ConflictException; /** * 系统配置定义解析服务。 @@ -32,7 +32,7 @@ class SystemConfigDefinitionService extends BaseService * 获取全部系统配置标签页。 * * @return array 标签页列表 - * @throws RuntimeException + * @throws ConflictException */ public function tabs(): array { @@ -57,7 +57,9 @@ class SystemConfigDefinitionService extends BaseService $key = $tab['key']; if (isset($seenKeys[$key])) { - throw new RuntimeException(sprintf('系统配置标签 key 重复:%s', $key)); + throw new ConflictException(sprintf('系统配置标签 key 重复:%s', $key), [ + 'key' => $key, + ]); } foreach ($tab['rules'] as $rule) { @@ -67,7 +69,9 @@ class SystemConfigDefinitionService extends BaseService } if (isset($seenFields[$field])) { - throw new RuntimeException(sprintf('系统配置项 key 重复:%s', $field)); + throw new ConflictException(sprintf('系统配置项 key 重复:%s', $field), [ + 'field' => $field, + ]); } $seenFields[$field] = true; @@ -300,4 +304,3 @@ class SystemConfigDefinitionService extends BaseService return str_starts_with($field, self::VIRTUAL_FIELD_PREFIX); } } - diff --git a/app/service/system/config/SystemConfigPageService.php b/app/service/system/config/SystemConfigPageService.php index 3d818dd..f3a0ea4 100644 --- a/app/service/system/config/SystemConfigPageService.php +++ b/app/service/system/config/SystemConfigPageService.php @@ -3,6 +3,7 @@ namespace app\service\system\config; use app\common\base\BaseService; +use app\common\constant\EventConstant; use app\exception\ValidationException; use app\repository\system\config\SystemConfigRepository; use Webman\Event\Event; @@ -147,7 +148,7 @@ class SystemConfigPageService extends BaseService } }); - Event::emit('system.config.changed', [ + Event::dispatch(EventConstant::SYSTEM_CONFIG_CHANGED, [ 'group_code' => (string) $tab['key'], ]); @@ -213,5 +214,3 @@ class SystemConfigPageService extends BaseService return (string) $value; } } - - diff --git a/app/service/system/user/AdminUserService.php b/app/service/system/user/AdminUserService.php index 9ec7de8..87ffbe5 100644 --- a/app/service/system/user/AdminUserService.php +++ b/app/service/system/user/AdminUserService.php @@ -5,6 +5,7 @@ namespace app\service\system\user; use app\common\base\BaseService; use app\common\constant\CommonConstant; use app\exception\ResourceNotFoundException; +use app\exception\ValidationException; use app\model\admin\AdminUser; use app\repository\system\user\AdminUserRepository; @@ -196,6 +197,40 @@ class AdminUserService extends BaseService ]; } + /** + * 修改当前管理员登录密码。 + * + * @param int $adminId 管理员ID + * @param array $data 密码数据 + * @return array 修改结果 + * @throws ResourceNotFoundException + * @throws ValidationException + */ + public function changePassword(int $adminId, array $data): array + { + $admin = $this->adminUserRepository->find($adminId); + if (!$admin) { + throw new ResourceNotFoundException('管理员不存在', ['admin_id' => $adminId]); + } + + $currentPassword = trim((string) ($data['current_password'] ?? '')); + $newPassword = trim((string) ($data['password'] ?? '')); + + if (!password_verify($currentPassword, (string) $admin->password_hash)) { + throw new ValidationException('旧密码不正确'); + } + + if ($currentPassword === $newPassword) { + throw new ValidationException('新密码不能与旧密码相同'); + } + + $this->adminUserRepository->updateById($adminId, [ + 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), + ]); + + return ['updated' => true]; + } + /** * 统一整理写入字段,并处理密码哈希。 * @@ -226,6 +261,3 @@ class AdminUserService extends BaseService } } - - - diff --git a/doc/INDEX.md b/doc/INDEX.md new file mode 100644 index 0000000..e055763 --- /dev/null +++ b/doc/INDEX.md @@ -0,0 +1,63 @@ +# 文档索引 + +这份索引只做导航,不重复正文。 + +## 总览 + +- [工作区 README](../README.md) +- [文档中心](./README.md) +- [项目总览](./overview.md) +- [架构与请求流](./architecture.md) +- [稳定口径](./standards.md) + +## 分领域文档 + +- [后端总说明](./backend/README.md) +- [后端路由](./backend/routing.md) +- [后端服务层](./backend/services.md) +- [后端命令](./backend/commands.md) +- [文件资产](./backend/files.md) +- [支付插件模板](./backend/payment-plugin-template.md) +- [支付运行时数据契约](./backend/payment-runtime-contract.md) +- [ePay 兼容层](./backend/compat.md) + +- [前端总说明](./frontend/README.md) +- [管理后台前端](./frontend/admin.md) +- [商户后台前端](./frontend/mer.md) +- [收银台前端](./frontend/cashier.md) +- [菜单说明](./frontend/menu.md) + +- [接口总说明](./api/README.md) +- [管理后台接口](./api/admin.md) +- [商户后台接口](./api/mer.md) +- [收银台与开放接口](./api/cashier.md) +- [ePay 兼容协议](./api/legacy/epay.md) + +- [数据库总说明](./db/README.md) +- [数据表目录](./db/tables.md) +- [当前 DDL](./db/payment-middle-ddl.sql) + +- [部署总说明](./deployment/README.md) +- [后端部署](./deployment/backend.md) +- [管理后台部署](./deployment/admin.md) +- [商户后台部署](./deployment/mer.md) +- [收银台部署](./deployment/cashier.md) +- [环境变量](./deployment/env.md) + +## 项目 README + +- [admin](../admin/README.md) +- [mer](../mer/README.md) +- [cashier](../cashier/README.md) +- [mpay](../mpay/README.md) + +## 协作与记录 + +- [接手指南](./agent-handoff.md) +- [阶段计划](./plan.md) +- [常见问题](./faq.md) +- [变更记录](./changelog.md) + +## 归档 + +- [旧版后端文档目录](../mpay/doc/INDEX.md) diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..ad21719 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,32 @@ +# 文档中心 + +`docs/` 是 `MPAY_V2` 工作区的统一文档入口。文档只记录当前代码和配置能支撑的事实;历史资料保留在 `mpay/doc/`,不作为最新口径。 + +## 快速阅读 + +1. [总入口](../README.md) +2. [文档索引](./INDEX.md) +3. [项目总览](./overview.md) +4. [架构与请求流](./architecture.md) +5. [后端说明](./backend/README.md) +6. [前端说明](./frontend/README.md) +7. [接口说明](./api/README.md) +8. [部署说明](./deployment/README.md) + +## 文档边界 + +- `overview.md`:项目定位、应用组成、核心链路。 +- `architecture.md`:工作区结构、请求入口、后端分层。 +- `standards.md`:开发与业务稳定口径。 +- `backend/`:后端路由、服务、命令、文件与插件运行时。 +- `frontend/`:三套前端的职责、命令、接口前缀和页面入口。 +- `api/`:按 `/adminapi`、`/merapi`、`/api`、旧版 ePay 兼容入口整理接口面。 +- `db/`:当前 DDL 与表目录。 +- `deployment/`:启动、构建、部署和环境变量。 + +## 维护原则 + +- 文档和代码冲突时,以 `mpay/app/route`、前端 `src/api`、`package.json`、`composer.json`、DDL 为准。 +- 总文档只写关键事实,不复制接口字段和业务实现细节。 +- 新增或改名路由时,同步更新对应的 `api/`、`frontend/` 或 `backend/routing.md`。 +- 新增环境变量时,先改模板文件,再更新 `deployment/env.md`。 diff --git a/doc/agent-handoff.md b/doc/agent-handoff.md new file mode 100644 index 0000000..ab65fa8 --- /dev/null +++ b/doc/agent-handoff.md @@ -0,0 +1,72 @@ +# 协作接手指南 + +这份文档给新协作者快速接手使用。稳定事实先看 [稳定口径](./standards.md)。 + +## 先看什么 + +1. [工作区 README](../README.md) +2. [项目总览](./overview.md) +3. [架构与请求流](./architecture.md) +4. [后端总说明](./backend/README.md) +5. [前端总说明](./frontend/README.md) +6. [接口总说明](./api/README.md) +7. [数据库总说明](./db/README.md) + +## 常用命令 + +后端: + +```bash +cd mpay +composer install +php webman start +php webman mpay:test --all +php webman system:config-sync +``` + +前端: + +```bash +cd admin +pnpm dev +pnpm build:prod + +cd ../mer +pnpm dev +pnpm build:prod + +cd ../cashier +pnpm dev +pnpm build +``` + +## 不要搞混的边界 + +- `admin`:页面 `/admin`,接口 `/adminapi`。 +- `mer`:页面 `/mer`,接口 `/merapi`。 +- `cashier`:页面 `/cashier`、`/payment`,接口 `/api/cashier`。 +- ePay V1:`/submit.php`、`/mapi.php`、`/api.php`。 +- ePay V2:`/api/pay`、`/api/merchant`、`/api/transfer`。 +- `mpay/doc/` 是旧资料归档,最新文档在 `docs/`。 + +## 优先查看的代码 + +- `mpay/app/route/admin.php` +- `mpay/app/route/mer.php` +- `mpay/app/route/api.php` +- `mpay/app/service/payment/order/PayOrderService.php` +- `mpay/app/service/payment/order/RefundService.php` +- `mpay/app/service/payment/runtime/PaymentRouteService.php` +- `mpay/app/service/payment/runtime/PaymentPluginManager.php` +- `mpay/app/service/payment/cashier/CashierService.php` +- `mpay/app/service/merchant/portal/MerchantPortalService.php` +- `admin/src/api/modules` +- `mer/src/api/modules` +- `cashier/src/api/cashier.ts` + +## 协作原则 + +- 文档和代码冲突时,先以代码为准,再修正文档。 +- 接口入口以 `mpay/app/route` 为准。 +- 前端 API 前缀以各项目 `src/api/index.ts` 为准。 +- 不在总文档重复接口字段,字段细节看控制器、校验器、协议文档和 DDL。 diff --git a/doc/api/README.md b/doc/api/README.md new file mode 100644 index 0000000..c874208 --- /dev/null +++ b/doc/api/README.md @@ -0,0 +1,21 @@ +# 接口总说明 + +接口按消费方和协议分组,真实路由以 `mpay/app/route/*.php` 为准。 + +## 当前路由面 + +| 路由 | 用途 | 文档 | +| --- | --- | --- | +| `/adminapi` | 管理后台接口 | [admin.md](./admin.md) | +| `/merapi` | 商户后台接口 | [mer.md](./mer.md) | +| `/api/cashier` | 收银台前端 JSON 接口 | [cashier.md](./cashier.md) | +| `/api/pay`、`/api/merchant`、`/api/transfer` | ePay V2 / 开放 API | [cashier.md](./cashier.md)、[legacy/epay.md](./legacy/epay.md) | +| `/submit.php`、`/mapi.php`、`/api.php` | ePay V1 兼容入口 | [legacy/epay.md](./legacy/epay.md) | + +## 通用约束 + +- HTTP 成功态使用 `200`。 +- 业务成功由响应体 `code=200` 表示。 +- 后台类接口通过登录 token 鉴权。 +- 开放支付接口通过商户 API 凭证和 ePay 签名规则鉴权。 +- 接口字段不要在总文档重复铺开,按具体协议文档或控制器/校验器查看。 diff --git a/doc/api/admin.md b/doc/api/admin.md new file mode 100644 index 0000000..84b2399 --- /dev/null +++ b/doc/api/admin.md @@ -0,0 +1,38 @@ +# 管理后台接口 + +`admin` 前端调用 `/adminapi`,接口定义在 `mpay/app/route/admin.php`。 + +## 基本信息 + +- 页面入口:`/admin` +- API 前缀:`/adminapi` +- 登录接口:`POST /login` +- 保护接口:`AdminAuthMiddleware` +- 前端封装:`admin/src/api/modules/*` + +## 模块速览 + +| 模块 | 主要路径 | +| --- | --- | +| 认证 | `/login`、`/logout`、`/user/profile` | +| 管理员 | `/admin-users` | +| 商户 | `/merchants`、`/merchants/{id}/overview`、`/merchants/{id}/reset-password`、`/merchants/{id}/issue-credential` | +| 商户 API 凭证 | `/merchant-api-credentials` | +| 商户分组与策略 | `/merchant-groups`、`/merchant-policies` | +| 支付方式 | `/payment-types` | +| 支付插件 | `/payment-plugins`、`/payment-plugins/refresh`、`/payment-plugins/{code}/schema` | +| 插件配置 | `/payment-plugin-confs` | +| 支付通道 | `/payment-channels`、`/payment-channels/route-options` | +| 轮询组 | `/payment-poll-groups`、`/payment-poll-group-channels`、`/payment-poll-group-binds` | +| 路由预览 | `/routes/resolve` | +| 文件资产 | `/file-asset`、`/file-asset/upload`、`/file-asset/import-remote`、`/file-asset/{id}/preview`、`/file-asset/{id}/download` | +| 交易 | `/pay-orders`、`/refund-orders`、`/refund-orders/{refundNo}/retry`、`/settlement-orders` | +| 资金 | `/merchant-accounts`、`/merchant-accounts/summary`、`/account-ledgers` | +| 运维 | `/channel-daily-stats`、`/channel-notify-logs`、`/pay-callback-logs`、`/merchant-notify-tasks`、`/merchant-notify-tasks/{notifyNo}/retry` | +| 系统 | `/system/menu-tree`、`/system/dict-items`、`/system-config-pages` | + +## 关联代码 + +- 控制器:`mpay/app/http/admin/controller` +- 校验器:`mpay/app/http/admin/validation` +- 前端接口:`admin/src/api/modules` diff --git a/doc/api/cashier.md b/doc/api/cashier.md new file mode 100644 index 0000000..97acb93 --- /dev/null +++ b/doc/api/cashier.md @@ -0,0 +1,42 @@ +# 收银台与开放接口 + +本文覆盖 `cashier` 前端使用的收银台接口,以及后端在 `/api` 下暴露的 ePay V2 / 开放接口。 + +## 页面入口 + +| 页面前缀 | 说明 | +| --- | --- | +| `/cashier` | 收银台首页和业务单入口 | +| `/payment` | 支付页、中转页、结果页 | + +后端在 `mpay/app/route/api.php` 中读取 `public/cashier/index.html` 返回前端入口。 + +## 收银台 JSON API + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/cashier/context` | 根据 `biz_no` 获取收银台上下文 | +| `POST` | `/api/cashier/confirm` | 确认支付,参数包含 `biz_no` 和支付类型 | +| `GET` | `/api/cashier/pay-order` | 根据 `pay_no` 获取支付单详情 | + +对应前端封装:`cashier/src/api/cashier.ts`。对应后端控制器:`CashierController`。 + +## ePay V2 / 开放 API + +| 分组 | 方法与路径 | +| --- | --- | +| 支付 | `ANY /api/pay/submit`、`POST /api/pay/create`、`POST /api/pay/query`、`POST /api/pay/refund`、`POST /api/pay/refundquery`、`POST /api/pay/close`、`ANY /api/pay/{payNo}/callback` | +| 商户 | `POST /api/merchant/info`、`POST /api/merchant/orders` | +| 转账 | `POST /api/transfer/submit`、`POST /api/transfer/query`、`POST /api/transfer/balance` | + +对应控制器:`EpayV2Controller`。签名与参数以 [ePay 兼容协议](./legacy/epay.md) 和校验器为准。 + +## ePay V1 兼容入口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `ANY` | `/submit.php` | 页面跳转支付 | +| `POST` | `/mapi.php` | 接口支付 | +| `ANY` | `/api.php` | 标准 API | + +对应控制器:`EpayV1Controller`。 diff --git a/doc/api/legacy/epay.md b/doc/api/legacy/epay.md new file mode 100644 index 0000000..4945055 --- /dev/null +++ b/doc/api/legacy/epay.md @@ -0,0 +1,14 @@ +# ePay 兼容协议归档 + +这里是 ePay 兼容协议的总入口索引。 + +## 版本列表 + +- [ePay V1 文档](./epay_v1.md) +- [ePay V2 文档](./epay_v2.md) + +## 说明 + +- `epay_v1.md` 对应原站点旧版 `doc_old.html`,协议以 `MD5` 为主。 +- `epay_v2.md` 对应原站点新版 `doc/index.html`,协议以 `RSA` 为主。 +- 其他文档仍可继续引用这个索引页,避免入口分散。 diff --git a/doc/api/legacy/epay_v1.md b/doc/api/legacy/epay_v1.md new file mode 100644 index 0000000..1f7e47b --- /dev/null +++ b/doc/api/legacy/epay_v1.md @@ -0,0 +1,332 @@ +# ePay V1 接口文档整理 + +> 本文件整理自原版文档 [https://epay.qcjy.cc/doc_old.html](https://epay.qcjy.cc/doc_old.html) +> +> 用途:作为 V1 兼容层和老接口迁移时的协议参考,不包含本项目当前实现细节。 + +## 1. 协议规则 + +| 项目 | 值 | +| --- | --- | +| 请求数据格式 | `application/x-www-form-urlencoded` | +| 返回数据格式 | `JSON` | +| 签名算法 | `MD5` | +| 字符编码 | `UTF-8` | + +## 2. 接口总览 + +| 入口 | 说明 | +| --- | --- | +| `submit.php` | 页面跳转支付 | +| `mapi.php` | API 接口支付 | +| `api.php?act=query` | 查询商户信息 | +| `api.php?act=settle` | 查询结算记录 | +| `api.php?act=order` | 查询单个订单 | +| `api.php?act=orders` | 批量查询订单 | +| `api.php?act=refund` | 提交订单退款 | + +## 3. 页面跳转支付 + +### 3.1 接口说明 + +- 用途:用户前台直接发起支付 +- 常见调用方式:`form` 表单提交,或拼成跳转链接 +- URL:`http://epay.qcjy.cc/submit.php` +- 请求方式:`POST` 或 `GET` +- 推荐方式:`POST` +- `type` 不传时,默认进入收银台流程 + +### 3.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 支付方式 | `type` | 否 | `String` | `alipay` | 支付方式列表 | +| 商户订单号 | `out_trade_no` | 是 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 异步通知地址 | `notify_url` | 是 | `String` | `http://www.pay.com/notify_url.php` | 服务器异步通知地址 | +| 跳转通知地址 | `return_url` | 是 | `String` | `http://www.pay.com/return_url.php` | 页面跳转通知地址 | +| 商品名称 | `name` | 是 | `String` | `VIP会员` | 超过 127 个字节会自动截取 | +| 商品金额 | `money` | 是 | `String` | `1.00` | 单位:元,最多 2 位小数 | +| 业务扩展参数 | `param` | 否 | `String` | `没有请留空` | 支付后原样返回 | +| 签名字符串 | `sign` | 是 | `String` | `202cb962ac59075b964b07152d234b70` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `MD5` | 默认为 `MD5` | + +### 3.3 说明 + +- `notify_url` 用于异步通知 +- `return_url` 用于支付完成后的前端跳转 +- `param` 会在支付完成后原样返回 + +## 4. API 接口支付 + +### 4.1 接口说明 + +- 用途:服务器后端发起支付请求 +- URL:`http://epay.qcjy.cc/mapi.php` +- 请求方式:`POST` +- 响应通常返回跳转链接、二维码链接或小程序跳转链接中的一种 + +### 4.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 支付方式 | `type` | 是 | `String` | `alipay` | 支付方式列表 | +| 商户订单号 | `out_trade_no` | 是 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 异步通知地址 | `notify_url` | 是 | `String` | `http://www.pay.com/notify_url.php` | 服务器异步通知地址 | +| 跳转通知地址 | `return_url` | 否 | `String` | `http://www.pay.com/return_url.php` | 页面跳转通知地址 | +| 商品名称 | `name` | 是 | `String` | `VIP会员` | 超过 127 个字节会自动截取 | +| 商品金额 | `money` | 是 | `String` | `1.00` | 单位:元,最多 2 位小数 | +| 用户IP地址 | `clientip` | 是 | `String` | `192.168.1.100` | 用户发起支付的 IP | +| 设备类型 | `device` | 否 | `String` | `pc` | 根据 UA 判断,默认 `pc` | +| 业务扩展参数 | `param` | 否 | `String` | `没有请留空` | 支付后原样返回 | +| 签名字符串 | `sign` | 是 | `String` | `202cb962ac59075b964b07152d234b70` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `MD5` | 默认为 `MD5` | + +### 4.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `1` | `1` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | | 失败时返回原因 | +| 订单号 | `trade_no` | `String` | `20160806151343349` | 支付订单号 | +| 支付跳转url | `payurl` | `String` | `http://epay.qcjy.cc/pay/wxpay/202010903/` | 直接跳转该 URL 支付 | +| 二维码链接 | `qrcode` | `String` | `weixin://wxpay/bizpayurl?pr=04IPMKM` | 按链接生成二维码 | +| 小程序跳转url | `urlscheme` | `String` | `weixin://dl/business/?ticket=xxx` | 使用 JS 跳转该 URL | + +### 4.4 说明 + +- `payurl`、`qrcode`、`urlscheme` 三者只会返回其中一个 +- `device` 和支付方式会影响最终返回值类型 +- 返回值为 JSON,由调用方自行决定跳转、展示二维码或拉起小程序 + +## 5. 支付结果通知 + +### 5.1 通知类型 + +- 服务器异步通知:`notify_url` +- 页面跳转通知:`return_url` + +### 5.2 请求方式 + +- `GET` + +### 5.3 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 易支付订单号 | `trade_no` | 是 | `String` | `20160806151343349021` | 平台订单号 | +| 商户订单号 | `out_trade_no` | 是 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 支付方式 | `type` | 是 | `String` | `alipay` | 支付方式列表 | +| 商品名称 | `name` | 是 | `String` | `VIP会员` | 商品名称 | +| 商品金额 | `money` | 是 | `String` | `1.00` | 商品金额 | +| 支付状态 | `trade_status` | 是 | `String` | `TRADE_SUCCESS` | 只有 `TRADE_SUCCESS` 才表示成功 | +| 业务扩展参数 | `param` | 否 | `String` | | 业务参数 | +| 签名字符串 | `sign` | 是 | `String` | `202cb962ac59075b964b07152d234b70` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `MD5` | 默认为 `MD5` | + +### 5.4 回调响应 + +- 收到异步通知后,需返回 `success` +- 页面跳转通知主要用于前端展示,不代表后台最终确认逻辑 + +## 6. MD5 签名算法 + +1. 将发送或接收到的所有参数按照参数名 ASCII 码从小到大排序。 +2. `sign`、`sign_type` 和空值不参与签名。 +3. 将排序后的参数拼接成 `a=b&c=d&e=f` 的形式,参数值不要进行 URL 编码。 +4. 将拼接字符串与商户密钥 `KEY` 进行 MD5 加密,得到签名。 + +```text +sign = md5(a=b&c=d&e=f + KEY) +``` + +说明: + +- `+` 代表字符串拼接,不是字符本身 +- MD5 结果为小写 +- 具体示例以 SDK 为准 + +## 7. 支付方式列表 + +| 调用值 | 描述 | +| --- | --- | +| `alipay` | 支付宝 | +| `wxpay` | 微信支付 | +| `qqpay` | QQ 钱包 | + +## 8. 设备类型列表 + +| 调用值 | 描述 | +| --- | --- | +| `pc` | 电脑浏览器 | +| `mobile` | 手机浏览器 | +| `qq` | 手机 QQ 内浏览器 | +| `wechat` | 微信内浏览器 | +| `alipay` | 支付宝客户端 | +| `jump` | 仅返回支付跳转 URL | + +## 9. [API]查询商户信息 + +### 9.1 接口说明 + +- URL:`http://epay.qcjy.cc/api.php?act=query&pid={商户ID}&key={商户密钥}` +- 操作类型:`query` + +### 9.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 操作类型 | `act` | 是 | `String` | `query` | 固定值 | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 商户密钥 | `key` | 是 | `String` | `89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i` | 商户密钥 | + +### 9.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `1` | `1` 为成功,其它值为失败 | +| 商户ID | `pid` | `Int` | `1001` | 商户ID | +| 商户密钥 | `key` | `String(32)` | `89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i` | 商户密钥 | +| 商户状态 | `active` | `Int` | `1` | `1` 为正常,`0` 为封禁 | +| 商户余额 | `money` | `String` | `0.00` | 商户所拥有的余额 | +| 结算方式 | `type` | `Int` | `1` | `1:支付宝,2:微信,3:QQ,4:银行卡` | +| 结算账号 | `account` | `String` | `admin@pay.com` | 结算账号 | +| 结算姓名 | `username` | `String` | `张三` | 结算姓名 | +| 订单总数 | `orders` | `Int` | `30` | 订单总数统计 | +| 今日订单 | `order_today` | `Int` | `15` | 今日订单数量 | +| 昨日订单 | `order_lastday` | `Int` | `15` | 昨日订单数量 | + +## 10. [API]查询结算记录 + +### 10.1 接口说明 + +- URL:`http://epay.qcjy.cc/api.php?act=settle&pid={商户ID}&key={商户密钥}` +- 操作类型:`settle` + +### 10.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 操作类型 | `act` | 是 | `String` | `settle` | 固定值 | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 商户密钥 | `key` | 是 | `String` | `89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i` | 商户密钥 | + +### 10.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `1` | `1` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `查询结算记录成功!` | 提示信息 | +| 结算记录 | `data` | `Array` | 结算记录列表 | 结算记录数组 | + +> 原版文档对 `data` 内部结构只给出“结算记录列表”的说明,没有展开逐字段定义。 + +## 11. [API]查询单个订单 + +### 11.1 接口说明 + +- URL:`http://epay.qcjy.cc/api.php?act=order&pid={商户ID}&key={商户密钥}&out_trade_no={商户订单号}` +- 操作类型:`order` + +### 11.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 操作类型 | `act` | 是 | `String` | `order` | 固定值 | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 商户密钥 | `key` | 是 | `String` | `89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i` | 商户密钥 | +| 系统订单号 | `trade_no` | 选择 | `String` | `20160806151343312` | 平台订单号 | +| 商户订单号 | `out_trade_no` | 选择 | `String` | `20160806151343349` | 商户自定义订单号 | + +说明: + +- `trade_no` 和 `out_trade_no` 二选一即可 +- 如果都传入,以 `trade_no` 为准 + +### 11.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `1` | `1` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `查询订单号成功!` | 提示信息 | +| 易支付订单号 | `trade_no` | `String` | `2016080622555342651` | 平台订单号 | +| 商户订单号 | `out_trade_no` | `String` | `20160806151343349` | 商户系统内部订单号 | +| 第三方订单号 | `api_trade_no` | `String` | `20160806151343349` | 支付渠道订单号 | +| 支付方式 | `type` | `String` | `alipay` | 支付方式列表 | +| 商户ID | `pid` | `Int` | `1001` | 发起支付的商户ID | +| 创建订单时间 | `addtime` | `String` | `2016-08-06 22:55:52` | 创建时间 | +| 完成交易时间 | `endtime` | `String` | `2016-08-06 22:55:52` | 完成时间 | +| 商品名称 | `name` | `String` | `VIP会员` | 商品名称 | +| 商品金额 | `money` | `String` | `1.00` | 商品金额 | +| 支付状态 | `status` | `Int` | `0` | `1` 表示支付成功,`0` 表示未支付 | +| 业务扩展参数 | `param` | `String` | | 默认留空 | +| 支付者账号 | `buyer` | `String` | | 默认留空 | + +## 12. [API]批量查询订单 + +### 12.1 接口说明 + +- URL:`http://epay.qcjy.cc/api.php?act=orders&pid={商户ID}&key={商户密钥}` +- 操作类型:`orders` + +### 12.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 操作类型 | `act` | 是 | `String` | `orders` | 固定值 | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 商户密钥 | `key` | 是 | `String` | `89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i` | 商户密钥 | +| 查询订单数量 | `limit` | 否 | `Int` | `20` | 返回订单数量,最大 `50` | +| 页码 | `page` | 否 | `Int` | `1` | 当前查询页码 | + +### 12.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `1` | `1` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `查询结算记录成功!` | 提示信息 | +| 订单列表 | `data` | `Array` | 订单列表 | 订单数组 | + +> `data` 中每一项与“查询单个订单”返回结构基本一致。 + +## 13. [API]提交订单退款 + +### 13.1 接口说明 + +- 需要先在商户后台开启订单退款 API 接口开关 +- URL:`http://epay.qcjy.cc/api.php?act=refund` +- 请求方式:`POST` + +### 13.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 商户密钥 | `key` | 是 | `String` | `89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i` | 商户密钥 | +| 易支付订单号 | `trade_no` | 特殊可选 | `String` | `20160806151343349021` | 平台订单号 | +| 商户订单号 | `out_trade_no` | 特殊可选 | `String` | `20160806151343349` | 下单时传入的商户订单号 | +| 退款金额 | `money` | 是 | `String` | `1.50` | 少数通道需要与原订单金额一致 | + +说明: + +- `trade_no` 和 `out_trade_no` 不能同时为空 +- 如果都传了,以 `trade_no` 为准 + +### 13.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `1` | `1` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `退款成功` | 提示信息 | + +## 14. SDK 下载 + +- 下载地址:[`SDK.zip`](https://epay.qcjy.cc/assets/files/SDK.zip?_v=1.3) +- 版本:`V1.3` + +## 15. 备注 + +- 原版文档页面还包含“产品”“关于我们”“联系我们”等站点导航内容,这里不做展开 +- 如果需要继续对照更多说明,优先以原站点文档为准 diff --git a/doc/api/legacy/epay_v2.md b/doc/api/legacy/epay_v2.md new file mode 100644 index 0000000..3864539 --- /dev/null +++ b/doc/api/legacy/epay_v2.md @@ -0,0 +1,518 @@ +# ePay V2 接口文档整理 + +> 本文件整理自原版文档 [https://epay.qcjy.cc/doc/index.html](https://epay.qcjy.cc/doc/index.html) +> +> 用途:作为 V2 兼容层和新接口迁移时的协议参考,不包含本项目当前实现细节。 + +## 1. 协议规则 + +| 项目 | 值 | +| --- | --- | +| 请求数据格式 | `application/x-www-form-urlencoded` | +| 返回数据格式 | `JSON` | +| 签名算法 | `SHA256WithRSA` | +| 字符编码 | `UTF-8` | + +## 2. V2 升级说明 + +1. V2 全面使用 RSA 签名,V1 保留 MD5 签名。 +2. V2 改用新的接口地址,支持退款、代付等能力。 +3. V2 增加 `timestamp` 参数和返回值,用于时间戳校验。 + +## 3. RSA 密钥对 + +- 在商户后台的 API 信息页面生成商户 RSA 密钥对。 +- 商户需要妥善保管私钥。 +- 对接时通常会同时使用平台公钥和商户私钥。 + +## 4. 签名规则 + +### 4.1 请求签名 + +1. 取所有非空参数。 +2. 排除 `sign`、`sign_type`。 +3. 排除数组、文件、二进制等非普通字段。 +4. 按参数名 ASCII 升序排序。 +5. 以 `k=v&k2=v2...` 的方式拼接原文。 +6. 使用商户私钥进行 `SHA256WithRSA` 签名。 + +### 4.2 验签规则 + +1. 按相同规则整理原文。 +2. 使用平台公钥验证签名。 +3. 请求和响应都应做签名校验。 + +## 5. 支付方式列表 + +| 调用值 | 描述 | +| --- | --- | +| `alipay` | 支付宝 | +| `wxpay` | 微信支付 | +| `qqpay` | QQ 钱包 | + +## 6. 设备类型列表 + +| 调用值 | 描述 | +| --- | --- | +| `pc` | 电脑浏览器 | +| `mobile` | 手机浏览器 | +| `qq` | 手机 QQ 内浏览器 | +| `wechat` | 微信内浏览器 | +| `alipay` | 支付宝客户端 | + +## 7. 接口总览 + +| 入口 | 说明 | +| --- | --- | +| `submit.php` / `/api/pay/submit` | 页面跳转支付 | +| `/api/pay/create` | API 创建订单 | +| `/api/pay/query` | 查询订单 | +| `/api/pay/notify` | 支付结果通知 | +| `/api/pay/refund` | 退款 | +| `/api/pay/refundquery` | 退款查询 | +| `/api/pay/close` | 关闭订单 | +| `/api/merchant/info` | 查询商户信息 | +| `/api/merchant/orders` | 查询商户订单 | +| `/api/transfer/submit` | 提交转账 | +| `/api/transfer/query` | 查询转账 | +| `/api/transfer/balance` | 查询转账余额 | + +## 8. 页面跳转支付 + +### 8.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/pay/submit` +- 用途:前台页面跳转支付 +- 请求方式:`POST` +- `type` 可不传,不传时进入收银台 + +### 8.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 支付方式 | `type` | 否 | `String` | `alipay` | 支付方式列表 | +| 商户订单号 | `out_trade_no` | 是 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 异步通知地址 | `notify_url` | 是 | `String` | `http://www.pay.com/notify_url.php` | 异步通知地址 | +| 跳转通知地址 | `return_url` | 是 | `String` | `http://www.pay.com/return_url.php` | 页面跳转通知地址 | +| 商品名称 | `name` | 是 | `String` | `VIP会员` | 商品名称 | +| 商品金额 | `money` | 是 | `String` | `1.00` | 单位:元 | +| 业务扩展参数 | `param` | 否 | `String` | | 支付后原样返回 | +| 渠道ID | `channel_id` | 否 | `String` | `1001` | 指定支付渠道 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +## 9. API 创建订单 + +### 9.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/pay/create` +- 用途:服务器端统一创建订单 +- 请求方式:`POST` + +### 9.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 接口类型 | `method` | 是 | `String` | `web` | `web`、`jump`、`jsapi`、`app`、`scan`、`applet` | +| 设备类型 | `device` | 否 | `String` | `pc` | `pc`、`mobile`、`qq`、`wechat`、`alipay` | +| 支付方式 | `type` | 是 | `String` | `alipay` | 支付方式列表 | +| 商户订单号 | `out_trade_no` | 是 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 异步通知地址 | `notify_url` | 是 | `String` | `http://www.pay.com/notify_url.php` | 异步通知地址 | +| 跳转通知地址 | `return_url` | 否 | `String` | `http://www.pay.com/return_url.php` | 页面跳转通知地址 | +| 商品名称 | `name` | 是 | `String` | `VIP会员` | 商品名称 | +| 商品金额 | `money` | 是 | `String` | `1.00` | 单位:元 | +| 用户IP地址 | `clientip` | 否 | `String` | `127.0.0.1` | 用户发起支付的 IP | +| 业务扩展参数 | `param` | 否 | `String` | | 支付后原样返回 | +| 授权码 | `auth_code` | 否 | `String` | | JSAPI / 刷脸类场景使用 | +| 子用户 OPENID | `sub_openid` | 否 | `String` | | 微信相关场景使用 | +| 子应用 APPID | `sub_appid` | 否 | `String` | | 微信相关场景使用 | +| 渠道ID | `channel_id` | 否 | `String` | `1001` | 指定通道 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 9.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 平台订单号 | `trade_no` | `String` | `2016080622555342651` | 易支付订单号 | +| 返回类型 | `pay_type` | `String` | `qrcode` | 返回内容类型 | +| 支付内容 | `pay_info` | `Mixed` | | 跳转链接、二维码内容或 JSAPI 参数 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +### 9.4 `pay_type` 说明 + +| 值 | 说明 | +| --- | --- | +| `jump` | 跳转链接 | +| `html` | HTML 片段 | +| `qrcode` | 二维码内容 | +| `urlscheme` | 小程序 URL Scheme | +| `jsapi` | JSAPI 参数 | +| `app` | APP 调起参数 | +| `scan` | 扫码支付结果信息 | +| `wxplugin` | 小程序插件参数 | +| `wxapp` | APP 拉起小程序参数 | + +## 10. 订单查询 + +### 10.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/pay/query` +- 请求方式:`POST` + +### 10.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 平台订单号 | `trade_no` | 二选一 | `String` | `2016080622555342651` | 易支付订单号 | +| 商户订单号 | `out_trade_no` | 二选一 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 10.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 平台订单号 | `trade_no` | `String` | | 平台订单号 | +| 商户订单号 | `out_trade_no` | `String` | | 商户订单号 | +| 第三方订单号 | `api_trade_no` | `String` | | 渠道订单号 | +| 支付方式 | `type` | `String` | `alipay` | 支付方式 | +| 支付状态 | `status` | `Int` | `1` | `0` 未支付,`1` 已支付,`2` 已退款,`3` 已冻结,`4` 预授权 | +| 商户ID | `pid` | `Int` | `1001` | 商户ID | +| 创建时间 | `addtime` | `String` | `2016-08-06 22:55:52` | 创建时间 | +| 完成时间 | `endtime` | `String` | `2016-08-06 22:55:52` | 完成时间 | +| 商品名称 | `name` | `String` | `VIP会员` | 商品名称 | +| 商品金额 | `money` | `String` | `1.00` | 商品金额 | +| 退款金额 | `refundmoney` | `String` | `0.00` | 已退款金额 | +| 业务扩展参数 | `param` | `String` | | 扩展参数 | +| 支付者账号 | `buyer` | `String` | | 支付者账号 | +| 用户IP | `clientip` | `String` | | 下单 IP | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 11. 支付通知 + +### 11.1 通知类型 + +- 异步通知:`notify_url` +- 页面跳转通知:`return_url` + +### 11.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 平台订单号 | `trade_no` | 是 | `String` | `2016080622555342651` | 易支付订单号 | +| 商户订单号 | `out_trade_no` | 是 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 支付方式 | `type` | 是 | `String` | `alipay` | 支付方式 | +| 支付状态 | `trade_status` | 是 | `String` | `TRADE_SUCCESS` | 成功状态固定为该值 | +| 创建时间 | `addtime` | 否 | `String` | `2016-08-06 22:55:52` | 创建时间 | +| 完成时间 | `endtime` | 否 | `String` | `2016-08-06 22:55:52` | 完成时间 | +| 商品名称 | `name` | 是 | `String` | `VIP会员` | 商品名称 | +| 商品金额 | `money` | 是 | `String` | `1.00` | 商品金额 | +| 业务扩展参数 | `param` | 否 | `String` | | 业务参数 | +| 支付者账号 | `buyer` | 否 | `String` | | 支付者账号 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 11.3 回调响应 + +- 收到异步通知后,返回 `success` +- 通知时要校验签名,并确认 `trade_status == TRADE_SUCCESS` + +## 12. 退款 + +### 12.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/pay/refund` +- 请求方式:`POST` + +### 12.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 平台订单号 | `trade_no` | 二选一 | `String` | `2016080622555342651` | 易支付订单号 | +| 商户订单号 | `out_trade_no` | 二选一 | `String` | `20160806151343349` | 商户系统内部订单号 | +| 退款金额 | `money` | 是 | `String` | `1.50` | 退款金额 | +| 商户退款单号 | `out_refund_no` | 否 | `String` | `R202604210001` | 商户侧退款流水号 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 12.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 退款单号 | `refund_no` | `String` | `202604210001` | 平台退款单号 | +| 商户退款单号 | `out_refund_no` | `String` | `R202604210001` | 商户退款单号 | +| 平台订单号 | `trade_no` | `String` | `2016080622555342651` | 易支付订单号 | +| 退款金额 | `money` | `String` | `1.50` | 退款金额 | +| 已退金额 | `reducemoney` | `String` | `1.50` | 累计退款金额 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 13. 退款查询 + +### 13.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/pay/refundquery` +- 请求方式:`POST` + +### 13.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 退款单号 | `refund_no` | 二选一 | `String` | `202604210001` | 平台退款单号 | +| 商户退款单号 | `out_refund_no` | 二选一 | `String` | `R202604210001` | 商户退款单号 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 13.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 退款单号 | `refund_no` | `String` | `202604210001` | 平台退款单号 | +| 商户退款单号 | `out_refund_no` | `String` | `R202604210001` | 商户退款单号 | +| 平台订单号 | `trade_no` | `String` | `2016080622555342651` | 易支付订单号 | +| 商户订单号 | `out_trade_no` | `String` | `20160806151343349` | 商户订单号 | +| 退款金额 | `money` | `String` | `1.50` | 退款金额 | +| 已退金额 | `reducemoney` | `String` | `1.50` | 累计退款金额 | +| 退款状态 | `status` | `Int` | `1` | `0` 失败,`1` 成功 | +| 创建时间 | `addtime` | `String` | `2016-08-06 22:55:52` | 创建时间 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 14. 关闭订单 + +### 14.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/pay/close` +- 请求方式:`POST` +- 仅支持部分支付插件 + +### 14.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 平台订单号 | `trade_no` | 二选一 | `String` | `2016080622555342651` | 易支付订单号 | +| 商户订单号 | `out_trade_no` | 二选一 | `String` | `20160806151343349` | 商户订单号 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 14.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 15. 商户信息 + +### 15.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/merchant/info` +- 请求方式:`POST` + +### 15.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 15.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 商户ID | `pid` | `Int` | `1001` | 商户ID | +| 商户状态 | `status` | `Int` | `1` | 商户状态 | +| 支付状态 | `pay_status` | `Int` | `1` | 支付开关 | +| 结算状态 | `settle_status` | `Int` | `1` | 结算开关 | +| 商户余额 | `money` | `String` | `0.00` | 可用余额 | +| 结算类型 | `settle_type` | `Int` | `1` | `1` 支付宝,`2` 微信,`3` QQ,`4` 银行卡 | +| 结算账号 | `settle_account` | `String` | `admin@pay.com` | 结算账号 | +| 结算姓名 | `settle_name` | `String` | `张三` | 结算姓名 | +| 订单总数 | `order_num` | `Int` | `30` | 订单总数 | +| 今日订单 | `order_num_today` | `Int` | `15` | 今日订单数量 | +| 昨日订单 | `order_num_lastday` | `Int` | `15` | 昨日订单数量 | +| 今日交易额 | `order_money_today` | `String` | `100.00` | 今日交易金额 | +| 昨日交易额 | `order_money_lastday` | `String` | `90.00` | 昨日交易金额 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 16. 商户订单 + +### 16.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/merchant/orders` +- 请求方式:`POST` + +### 16.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 偏移量 | `offset` | 否 | `Int` | `0` | 起始偏移 | +| 数量 | `limit` | 否 | `Int` | `20` | 返回条数 | +| 状态 | `status` | 否 | `Int` | `1` | 订单状态筛选 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 16.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 订单数据 | `data` | `Array` | 订单列表 | 订单数组 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 17. 转账提交 + +### 17.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/transfer/submit` +- 请求方式:`POST` + +### 17.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 转账类型 | `type` | 是 | `String` | `alipay` | `alipay`、`wxpay`、`qqpay`、`bank` | +| 收款账号 | `account` | 是 | `String` | `admin@pay.com` | 收款账号 | +| 收款姓名 | `name` | 是 | `String` | `张三` | 收款姓名 | +| 金额 | `money` | 是 | `String` | `1.00` | 转账金额 | +| 备注 | `remark` | 否 | `String` | `测试转账` | 转账备注 | +| 商户转账单号 | `out_biz_no` | 否 | `String` | `T202604210001` | 商户侧流水号 | +| 书签 ID | `bookid` | 否 | `String` | `1` | 账本或预设项标识 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 17.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 转账状态 | `status` | `Int` | `0` | `0` 待处理,后续查询 | +| 平台业务号 | `biz_no` | `String` | `202604210001` | 平台转账业务号 | +| 商户转账单号 | `out_biz_no` | `String` | `T202604210001` | 商户侧流水号 | +| 订单号 | `orderid` | `String` | `202604210001` | 平台订单号 | +| 支付时间 | `paydate` | `String` | `2016-08-06 22:55:52` | 完成时间 | +| 手续费 | `cost_money` | `String` | `0.10` | 扣除费用 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 18. 转账查询 + +### 18.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/transfer/query` +- 请求方式:`POST` + +### 18.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 平台业务号 | `biz_no` | 二选一 | `String` | `202604210001` | 平台转账业务号 | +| 商户转账单号 | `out_biz_no` | 二选一 | `String` | `T202604210001` | 商户侧流水号 | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 18.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 转账状态 | `status` | `Int` | `1` | `0` 处理中,`1` 成功,`2` 失败 | +| 错误信息 | `errmsg` | `String` | | 失败原因 | +| 平台业务号 | `biz_no` | `String` | `202604210001` | 平台转账业务号 | +| 商户转账单号 | `out_biz_no` | `String` | `T202604210001` | 商户侧流水号 | +| 订单号 | `orderid` | `String` | `202604210001` | 平台订单号 | +| 支付时间 | `paydate` | `String` | `2016-08-06 22:55:52` | 完成时间 | +| 金额 | `amount` | `String` | `1.00` | 转账金额 | +| 手续费 | `cost_money` | `String` | `0.10` | 扣除费用 | +| 备注 | `remark` | `String` | `测试转账` | 备注信息 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 19. 转账余额 + +### 19.1 接口说明 + +- URL:`http://epay.qcjy.cc/api/transfer/balance` +- 请求方式:`POST` + +### 19.2 请求参数 + +| 字段名 | 变量名 | 必填 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| 商户ID | `pid` | 是 | `Int` | `1001` | 商户ID | +| 时间戳 | `timestamp` | 是 | `String` | `1713660000` | 用于时间校验 | +| 签名字符串 | `sign` | 是 | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | 是 | `String` | `SHA256WithRSA` | 签名类型 | + +### 19.3 返回结果 + +| 字段名 | 变量名 | 类型 | 示例值 | 描述 | +| --- | --- | --- | --- | --- | +| 返回状态码 | `code` | `Int` | `0` | `0` 为成功,其它值为失败 | +| 返回信息 | `msg` | `String` | `success` | 提示信息 | +| 可用余额 | `available_money` | `String` | `100.00` | 可转账余额 | +| 转账费率 | `transfer_rate` | `String` | `0.01` | 转账费率 | +| 时间戳 | `timestamp` | `String` | `1713660000` | 返回时间戳 | +| 签名字符串 | `sign` | `String` | `...` | 签名结果 | +| 签名类型 | `sign_type` | `String` | `SHA256WithRSA` | 签名类型 | + +## 20. SDK 下载 + +- 下载地址:[`SDK_2.0.zip`](https://epay.qcjy.cc/assets/files/SDK_2.0.zip) +- 版本:`V2.0` + +## 21. 备注 + +- V2 文档页面还包含更细的接口说明、页面示例和菜单导航,这里只整理对接最关键的协议信息。 +- 如果后续需要补充每个接口的示例请求与返回样例,可以继续在本目录补充,但建议保持版本归档方式一致。 diff --git a/doc/api/legacy/sdk/v1/epayapi.php b/doc/api/legacy/sdk/v1/epayapi.php new file mode 100644 index 0000000..a91eba5 --- /dev/null +++ b/doc/api/legacy/sdk/v1/epayapi.php @@ -0,0 +1,60 @@ + + + + + + 正在为您跳转到支付页面,请稍候... + + + + $epay_config['pid'], + "type" => $type, + "notify_url" => $notify_url, + "return_url" => $return_url, + "out_trade_no" => $out_trade_no, + "name" => $name, + "money" => $money, +); + +//建立请求 +$epay = new EpayCore($epay_config); +$html_text = $epay->pagePay($parameter); +echo $html_text; + +?> +

正在为您跳转到支付页面,请稍候...

+ + \ No newline at end of file diff --git a/doc/api/legacy/sdk/v1/index.php b/doc/api/legacy/sdk/v1/index.php new file mode 100644 index 0000000..207f60c --- /dev/null +++ b/doc/api/legacy/sdk/v1/index.php @@ -0,0 +1,205 @@ + + + + + 彩虹易支付接口测试 + + + + +
+ +
+
    +
  1. 1、确认信息 →
  2. +
  3. 2、点击确认 →
  4. +
  5. 3、确认完成
  6. +
+
+
+
+
+
商户订单号:
+
+ * + "/> + 商户网站订单系统中唯一订单号,必填 + +
+
商品名称:
+
+ * + + 必填 + +
+
付款金额:
+
+ * + + 必填 + +
+
支付方式:
+
+      +
+ + +
+
+ +
+
+
+
+ +
+ + \ No newline at end of file diff --git a/doc/api/legacy/sdk/v1/lib/EpayCore.class.php b/doc/api/legacy/sdk/v1/lib/EpayCore.class.php new file mode 100644 index 0000000..b3a5a78 --- /dev/null +++ b/doc/api/legacy/sdk/v1/lib/EpayCore.class.php @@ -0,0 +1,154 @@ +pid = $config['pid']; + $this->key = $config['key']; + $this->submit_url = $config['apiurl'].'submit.php'; + $this->mapi_url = $config['apiurl'].'mapi.php'; + $this->api_url = $config['apiurl'].'api.php'; + } + + // 发起支付(页面跳转) + public function pagePay($param_tmp, $button='正在跳转'){ + $param = $this->buildRequestParam($param_tmp); + + $html = '
'; + foreach ($param as $k=>$v) { + $html.= ''; + } + $html .= '
'; + + return $html; + } + + // 发起支付(获取链接) + public function getPayLink($param_tmp){ + $param = $this->buildRequestParam($param_tmp); + $url = $this->submit_url.'?'.http_build_query($param); + return $url; + } + + // 发起支付(API接口) + public function apiPay($param_tmp){ + $param = $this->buildRequestParam($param_tmp); + $response = $this->getHttpResponse($this->mapi_url, http_build_query($param)); + $arr = json_decode($response, true); + return $arr; + } + + // 异步回调验证 + public function verifyNotify(){ + if(empty($_GET)) return false; + + $sign = $this->getSign($_GET); + + if($sign === $_GET['sign']){ + $signResult = true; + }else{ + $signResult = false; + } + + return $signResult; + } + + // 同步回调验证 + public function verifyReturn(){ + if(empty($_GET)) return false; + + $sign = $this->getSign($_GET); + + if($sign === $_GET['sign']){ + $signResult = true; + }else{ + $signResult = false; + } + + return $signResult; + } + + // 查询订单支付状态 + public function orderStatus($trade_no){ + $result = $this->queryOrder($trade_no); + if($result['status']==1){ + return true; + }else{ + return false; + } + } + + // 查询订单 + public function queryOrder($trade_no){ + $url = $this->api_url.'?act=order&pid=' . $this->pid . '&key=' . $this->key . '&trade_no=' . $trade_no; + $response = $this->getHttpResponse($url); + $arr = json_decode($response, true); + return $arr; + } + + // 订单退款 + public function refund($trade_no, $money){ + $url = $this->api_url.'?act=refund'; + $post = 'pid=' . $this->pid . '&key=' . $this->key . '&trade_no=' . $trade_no . '&money=' . $money; + $response = $this->getHttpResponse($url, $post); + $arr = json_decode($response, true); + return $arr; + } + + private function buildRequestParam($param){ + $mysign = $this->getSign($param); + $param['sign'] = $mysign; + $param['sign_type'] = $this->sign_type; + return $param; + } + + // 计算签名 + private function getSign($param){ + ksort($param); + reset($param); + $signstr = ''; + + foreach($param as $k => $v){ + if($k != "sign" && $k != "sign_type" && $v!=''){ + $signstr .= $k.'='.$v.'&'; + } + } + $signstr = substr($signstr,0,-1); + $signstr .= $this->key; + $sign = md5($signstr); + return $sign; + } + + // 请求外部资源 + private function getHttpResponse($url, $post = false, $timeout = 10){ + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + $httpheader[] = "Accept: */*"; + $httpheader[] = "Accept-Language: zh-CN,zh;q=0.8"; + $httpheader[] = "Connection: close"; + curl_setopt($ch, CURLOPT_HTTPHEADER, $httpheader); + curl_setopt($ch, CURLOPT_HEADER, false); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + if($post){ + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post); + } + $response = curl_exec($ch); + curl_close($ch); + return $response; + } +} diff --git a/doc/api/legacy/sdk/v1/lib/epay.config.php b/doc/api/legacy/sdk/v1/lib/epay.config.php new file mode 100644 index 0000000..5c1fe69 --- /dev/null +++ b/doc/api/legacy/sdk/v1/lib/epay.config.php @@ -0,0 +1,13 @@ +verifyNotify(); + +if($verify_result) {//验证成功 + + //商户订单号 + $out_trade_no = $_GET['out_trade_no']; + + //彩虹易支付交易号 + $trade_no = $_GET['trade_no']; + + //交易状态 + $trade_status = $_GET['trade_status']; + + //支付方式 + $type = $_GET['type']; + + //支付金额 + $money = $_GET['money']; + + if ($_GET['trade_status'] == 'TRADE_SUCCESS') { + //判断该笔订单是否在商户网站中已经做过处理 + //如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序 + //如果有做过处理,不执行商户的业务程序 + } + + //验证成功返回 + echo "success"; +} +else { + //验证失败 + echo "fail"; +} +?> \ No newline at end of file diff --git a/doc/api/legacy/sdk/v1/return_url.php b/doc/api/legacy/sdk/v1/return_url.php new file mode 100644 index 0000000..6c14466 --- /dev/null +++ b/doc/api/legacy/sdk/v1/return_url.php @@ -0,0 +1,55 @@ + + + + + + 支付返回页面 + + +verifyReturn(); + +if($verify_result) {//验证成功 + + //商户订单号 + $out_trade_no = $_GET['out_trade_no']; + + //支付宝交易号 + $trade_no = $_GET['trade_no']; + + //交易状态 + $trade_status = $_GET['trade_status']; + + //支付方式 + $type = $_GET['type']; + + + if($_GET['trade_status'] == 'TRADE_SUCCESS') { + //判断该笔订单是否在商户网站中已经做过处理 + //如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序 + //如果有做过处理,不执行商户的业务程序 + } + else { + echo "trade_status=".$_GET['trade_status']; + } + + echo "

验证成功


"; +} +else { + //验证失败 + echo "

验证失败

"; +} +?> + + \ No newline at end of file diff --git a/doc/api/legacy/sdk/v2/epayapi.php b/doc/api/legacy/sdk/v2/epayapi.php new file mode 100644 index 0000000..963146b --- /dev/null +++ b/doc/api/legacy/sdk/v2/epayapi.php @@ -0,0 +1,59 @@ + + + + + + 正在为您跳转到支付页面,请稍候... + + + + $type, + "notify_url" => $notify_url, + "return_url" => $return_url, + "out_trade_no" => $out_trade_no, + "name" => $name, + "money" => $money, +); + +//建立请求 +$epay = new EpayCore($epay_config); +$html_text = $epay->pagePay($parameter); +echo $html_text; + +?> +

正在为您跳转到支付页面,请稍候...

+ + \ No newline at end of file diff --git a/doc/api/legacy/sdk/v2/index.php b/doc/api/legacy/sdk/v2/index.php new file mode 100644 index 0000000..a15a4f4 --- /dev/null +++ b/doc/api/legacy/sdk/v2/index.php @@ -0,0 +1,61 @@ + + + + + + + 彩虹易支付接口测试 + + + + +
+
+ +
+
+ +
+
+ +
+ " autocomplete="off"> +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+   +   +   +   +
+
+
+
+

+
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/doc/api/legacy/sdk/v2/lib/EpayCore.class.php b/doc/api/legacy/sdk/v2/lib/EpayCore.class.php new file mode 100644 index 0000000..19f8426 --- /dev/null +++ b/doc/api/legacy/sdk/v2/lib/EpayCore.class.php @@ -0,0 +1,184 @@ +apiurl = $config['apiurl']; + $this->pid = $config['pid']; + $this->platform_public_key = $config['platform_public_key']; + $this->merchant_private_key = $config['merchant_private_key']; + } + + // 发起支付(页面跳转) + public function pagePay($param_tmp, $button='正在跳转'){ + $requrl = $this->apiurl.'api/pay/submit'; + $param = $this->buildRequestParam($param_tmp); + + $html = '
'; + foreach ($param as $k=>$v) { + $html.= ''; + } + $html .= '
'; + + return $html; + } + + // 发起支付(获取链接) + public function getPayLink($param_tmp){ + $requrl = $this->apiurl.'api/pay/submit'; + $param = $this->buildRequestParam($param_tmp); + $url = $requrl.'?'.http_build_query($param); + return $url; + } + + // 发起支付(API接口) + public function apiPay($params){ + return $this->execute('api/pay/create', $params); + } + + // 发起API请求 + public function execute($path, $params){ + $path = ltrim($path, '/'); + $requrl = $this->apiurl.$path; + $param = $this->buildRequestParam($params); + $response = $this->getHttpResponse($requrl, http_build_query($param)); + $arr = json_decode($response, true); + if($arr && $arr['code'] == 0){ + if(!$this->verify($arr)){ + throw new \Exception('返回数据验签失败'); + } + return $arr; + }else{ + throw new \Exception($arr ? $arr['msg'] : '请求失败'); + } + } + + // 回调验证 + public function verify($arr){ + if(empty($arr) || empty($arr['sign'])) return false; + + if(empty($arr['timestamp']) || abs(time() - $arr['timestamp']) > 300) return false; + + $sign = $arr['sign']; + + return $this->rsaPublicVerify($this->getSignContent($arr), $sign); + } + + // 查询订单支付状态 + public function orderStatus($trade_no){ + $result = $this->queryOrder($trade_no); + if($result && $result['status']==1){ + return true; + }else{ + return false; + } + } + + // 查询订单 + public function queryOrder($trade_no){ + $params = [ + 'trade_no' => $trade_no, + ]; + return $this->execute('api/pay/query', $params); + } + + // 订单退款 + public function refund($out_refund_no, $trade_no, $money){ + $params = [ + 'trade_no' => $trade_no, + 'money' => $money, + 'out_refund_no' => $out_refund_no, + ]; + return $this->execute('api/pay/refund', $params); + } + + private function buildRequestParam($params){ + $params['pid'] = $this->pid; + $params['timestamp'] = time().''; + $mysign = $this->getSign($params); + $params['sign'] = $mysign; + $params['sign_type'] = $this->sign_type; + return $params; + } + + // 生成签名 + private function getSign($params){ + return $this->rsaPrivateSign($this->getSignContent($params)); + } + + // 获取待签名字符串 + private function getSignContent($params){ + ksort($params); + $signstr = ''; + foreach ($params as $k => $v) { + if(is_array($v) || $this->isEmpty($v) || $k == 'sign' || $k == 'sign_type') continue; + $signstr .= '&' . $k . '=' . $v; + } + $signstr = substr($signstr, 1); + return $signstr; + } + + private function isEmpty($value) + { + return $value === null || trim($value) === ''; + } + + // 商户私钥签名 + private function rsaPrivateSign($data){ + $key = "-----BEGIN PRIVATE KEY-----\n" . + wordwrap($this->merchant_private_key, 64, "\n", true) . + "\n-----END PRIVATE KEY-----"; + $privatekey = openssl_get_privatekey($key); + if(!$privatekey){ + throw new \Exception('签名失败,商户私钥错误'); + } + openssl_sign($data, $sign, $privatekey, OPENSSL_ALGO_SHA256); + return base64_encode($sign); + } + + // 平台公钥验签 + private function rsaPublicVerify($data, $sign){ + $key = "-----BEGIN PUBLIC KEY-----\n" . + wordwrap($this->platform_public_key, 64, "\n", true) . + "\n-----END PUBLIC KEY-----"; + $publickey = openssl_get_publickey($key); + if (!$publickey) { + throw new \Exception("验签失败,平台公钥错误"); + } + $result = openssl_verify($data, base64_decode($sign), $publickey, OPENSSL_ALGO_SHA256); + return $result === 1; + } + + // 请求外部资源 + private function getHttpResponse($url, $post = false, $timeout = 10){ + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + $httpheader[] = "Accept: */*"; + $httpheader[] = "Accept-Language: zh-CN,zh;q=0.8"; + $httpheader[] = "Connection: close"; + curl_setopt($ch, CURLOPT_HTTPHEADER, $httpheader); + curl_setopt($ch, CURLOPT_HEADER, false); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + if($post){ + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post); + } + $response = curl_exec($ch); + curl_close($ch); + return $response; + } +} diff --git a/doc/api/legacy/sdk/v2/lib/epay.config.php b/doc/api/legacy/sdk/v2/lib/epay.config.php new file mode 100644 index 0000000..0b2a500 --- /dev/null +++ b/doc/api/legacy/sdk/v2/lib/epay.config.php @@ -0,0 +1,19 @@ + 'http://pay.www.com/', + + //商户ID + 'pid' => '1000', + + //平台公钥 + 'platform_public_key' => 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApHG7SIN16fd9uZfjZunZuAReemVQe5YNxBhbkogsRkZ86xuDVDCmhRXEzw7Ta3tXPnMIFRJFdjOCfFVarqcOLICtBiiZZ7Y4D6aIMhmOSliIJ3qWUnU75Wr2WMTIJ1o2pnPmczQ2YjAAy1DtQCc/qs35j24zuNYZw2WluSdiMckPFgge93RK6cq/Feqfuzq7y+m87x02gxbbTGVf24YH2f7H9qZSKCxRXHQoVIWTlyHULcY3OY+1CVdU2SKlIWHJ31eoPznXBLUo0UB0rNZnYrHG2mIlD2S119UTwZwx9WTG/v7Cb2lHVybjfL5/KLitddfqcLjJsYXh6KhEtsO6CwIDAQAB', + + //商户私钥 + 'merchant_private_key' => 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCBIB1e5lAYtFyXq5I8UIQ6KidYZcWkn0SwVS8Rk0SNZVrvL/UJk6Q1zkJs4pUCykTBS/tTrP2rNPOsK1VO/AQHIzhvAujsv7UK2LptcsuNRPCF5GYxndQnOawAGKNQKsMuNcDzyuyTMbZIBEYSRWIoU3dMz4wWEFso/VdVS4uKTZWZnBOeCDdzDAJ7TwbmaOkT919DfZbXAoMH9n3sG4BMpqQExTDoFY6dq6EPXCWVZgoUfecAgNKSfX5TagSUaAxq4eF5vsUfvj+LFpYIrIssmSVErtZuRXLHWVSEbsNxdDPNuS3BtxWEY7GRPF9RJevtoC5L5LN7Gn+RYCqZNZv7AgMBAAECggEAEA6ZTb11hQzwsrUAM1s5MNkgbsABIDk6BnTMAfMpRC1awyxYhqoDHTnFTYWuTVwvyUW/PtGKnelbdTPSS5x6jRSr0N+GGDgNYF2Wbpkm3Ni6Jubsb7ZrtRED5Y3Vc9j4JTKZXaJaDEJ9+LNSBLWiFi0C7zH5U/O8ElB8CrxL4ZUaZv0JgV9NcDpS5jAtpPSyBLrdhbEheertJiHQU0V+FaaXq8taNcYIA/Xim6+vqcFFtUA3PBBTXHn/NE5uasXi+N+De4IT+dBmirzVSZjviDPr9RSBUi6KPUSXx6eDa26SKeEqJZvBtlASDM+ZC0yhDz0eyV49tMjk7eF5fnCIwQKBgQC2nEiR2t5Q02tHaKesZMRGOwxEyMFQj6viDW+Yffg59Tu6QYuqdR558/zmzWcJFMH3DVQzTXpzPNU9TA3/yT/Q42iKBP70K8O9tJO+gd/jLHLqgw90Wyh2b4FJXXQqVQMkxGBQKRfNi6krWigJNBs8Z8IhczorQHYNbBIUI05poQKBgQC1BRI8zKf+85GuJXTxJ93RXbkOQMUIhT/6eyFTZvCLC9Qqba1/1ouNbtmxNsFFIC+n+rHRN9btKt90m9YFvXD90m3y34M88QjvaQcA1Kng9Q6Xia8DizpVIYGAR/Pfn36BZQeHHVz9te6QJ9hVOgZO3GG62Echd9M/rwOzuU14GwKBgFGtS2Q5khByz9wLuluIYqXLCWzGoninGkksm0qIpXs+7e0cHh0q72u6rtaI7toH983Jn2ym7esXPYWCPAy5dhq3bG23WFXcMVvrpd2i94IDwo6T+lif4VRAAYLQEwJQLezHDREtoCDmo87pL1kWfkwhWJpfkJgB6AuO1/M763mhAoGBAIPEGj9plcwOzndeSp6UL3IMb/1BBmuqWyTgZiTIpMYCKUFtLsMEj/a2vv2xZsQDpsz2vmMV63weHiRKn2L0QABzIZeOPYCpz6A96lwfcT0QBLwn+95vhVmclyCiv5GDDtnviag/poYD3ZDPgDihkR/sabNRZY2mJH6RzfcQJqULAoGALkSkqr0bplhfyAA6bO42l64th4YUqwouTEgp7rE36wQ28THj0a88HLU4CeiCR6LQAEGpKk04Vst97C1Q5ZeD5rc4xKINl8K5HUH8SsdMDq3r22xur2qr4kanW4hf2P/ehOeEKGuhSL+ZWeApvt1c0rqH4MQT1/7qR/dO2MikkMg=', +]; + diff --git a/doc/api/legacy/sdk/v2/notify_url.php b/doc/api/legacy/sdk/v2/notify_url.php new file mode 100644 index 0000000..4aafe6e --- /dev/null +++ b/doc/api/legacy/sdk/v2/notify_url.php @@ -0,0 +1,45 @@ +verify($_GET); + +if($verify_result) {//验证成功 + + //商户订单号 + $out_trade_no = $_GET['out_trade_no']; + + //彩虹易支付交易号 + $trade_no = $_GET['trade_no']; + + //交易状态 + $trade_status = $_GET['trade_status']; + + //支付方式 + $type = $_GET['type']; + + //支付金额 + $money = $_GET['money']; + + if ($_GET['trade_status'] == 'TRADE_SUCCESS') { + //判断该笔订单是否在商户网站中已经做过处理 + //如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序 + //如果有做过处理,不执行商户的业务程序 + } + + //验证成功返回 + echo "success"; +} +else { + //验证失败 + echo "fail"; +} +?> \ No newline at end of file diff --git a/doc/api/legacy/sdk/v2/query.php b/doc/api/legacy/sdk/v2/query.php new file mode 100644 index 0000000..49d11e7 --- /dev/null +++ b/doc/api/legacy/sdk/v2/query.php @@ -0,0 +1,17 @@ +queryOrder($trade_no); +}catch(Exception $e){ + echo $e->getMessage(); + exit; +} + +print_r($result); \ No newline at end of file diff --git a/doc/api/legacy/sdk/v2/refund.php b/doc/api/legacy/sdk/v2/refund.php new file mode 100644 index 0000000..05ce231 --- /dev/null +++ b/doc/api/legacy/sdk/v2/refund.php @@ -0,0 +1,19 @@ +refund($out_refund_no, $trade_no, $money); +}catch(Exception $e){ + echo $e->getMessage(); + exit; +} + +print_r($result); \ No newline at end of file diff --git a/doc/api/legacy/sdk/v2/return_url.php b/doc/api/legacy/sdk/v2/return_url.php new file mode 100644 index 0000000..3ae3367 --- /dev/null +++ b/doc/api/legacy/sdk/v2/return_url.php @@ -0,0 +1,55 @@ + + + + + + 支付返回页面 + + +verify($_GET); + +if($verify_result) {//验证成功 + + //商户订单号 + $out_trade_no = $_GET['out_trade_no']; + + //支付宝交易号 + $trade_no = $_GET['trade_no']; + + //交易状态 + $trade_status = $_GET['trade_status']; + + //支付方式 + $type = $_GET['type']; + + + if($_GET['trade_status'] == 'TRADE_SUCCESS') { + //判断该笔订单是否在商户网站中已经做过处理 + //如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序 + //如果有做过处理,不执行商户的业务程序 + } + else { + echo "trade_status=".$_GET['trade_status']; + } + + echo "

验证成功


"; +} +else { + //验证失败 + echo "

验证失败

"; +} +?> + + \ No newline at end of file diff --git a/doc/api/mer.md b/doc/api/mer.md new file mode 100644 index 0000000..ec9fcaf --- /dev/null +++ b/doc/api/mer.md @@ -0,0 +1,37 @@ +# 商户后台接口 + +`mer` 前端调用 `/merapi`,接口定义在 `mpay/app/route/mer.php`。 + +## 基本信息 + +- 页面入口:`/mer` +- API 前缀:`/merapi` +- 登录接口:`POST /login` +- 登录主体:`ma_merchant` +- 保护接口:`MerchantAuthMiddleware` +- 前端封装:`mer/src/api/modules/*` + +## 模块速览 + +| 模块 | 主要路径 | +| --- | --- | +| 认证 | `/login`、`/logout`、`/user/profile` | +| 商户资料 | `/merchant/profile`、`/merchant/change-password` | +| 通道与路由 | `/my-channels`、`/my-channels/create-meta`、`/plugin-configs`、`/plugin-configs/options`、`/payment-plugins/{code}/schema`、`/route-preview` | +| API 凭证 | `/api-credential`、`/api-credential/issue-credential` | +| 订单 | `/pay-orders`、`/refund-orders`、`/refund-orders/{refundNo}`、`/refund-orders/{refundNo}/retry` | +| 清算 | `/settlement-records`、`/settlement-records/{settleNo}` | +| 资金 | `/withdrawable-balance`、`/balance-flows` | +| 系统 | `/system/menu-tree`、`/system/dict-items` | + +## 关联代码 + +- 控制器:`mpay/app/http/mer/controller` +- 校验器:`mpay/app/http/mer/validation` +- 前端接口:`mer/src/api/modules` + +## 商户自助通道 + +- 商户可新增、修改、删除 `merchant_id=当前商户` 的自有通道。 +- 商户新增插件配置时只能选择管理后台标记为“允许商户端自助使用”的启用插件。 +- 商户通道绑定的 `api_config_id` 必须属于当前商户,不能引用平台配置或其它商户配置。 diff --git a/doc/api/pages/alipay_h5.php b/doc/api/pages/alipay_h5.php new file mode 100644 index 0000000..337d7e6 --- /dev/null +++ b/doc/api/pages/alipay_h5.php @@ -0,0 +1,109 @@ + + + + + + + + + 支付宝支付手机版 + + + + + +
+
+ +
+ + +
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/alipay_jspay.php b/doc/api/pages/alipay_jspay.php new file mode 100644 index 0000000..089f469 --- /dev/null +++ b/doc/api/pages/alipay_jspay.php @@ -0,0 +1,84 @@ + + + + + + + 支付宝支付 + + + +
+
+
+
+
+

正在跳转支付...

+
+
+
+
+ + + + + \ No newline at end of file diff --git a/doc/api/pages/alipay_qrcode.php b/doc/api/pages/alipay_qrcode.php new file mode 100644 index 0000000..eed77b0 --- /dev/null +++ b/doc/api/pages/alipay_qrcode.php @@ -0,0 +1,169 @@ + + + + + + + + +支付宝扫码支付 + + + + + +
+

+支付宝扫码支付 +

+
+
+
+
¥
+
+
+ +
+ + +
+
+ + +
+
+

请使用支付宝扫一扫

+

扫描二维码完成支付

+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/alipay_qrcodepc.php b/doc/api/pages/alipay_qrcodepc.php new file mode 100644 index 0000000..7b9c5eb --- /dev/null +++ b/doc/api/pages/alipay_qrcodepc.php @@ -0,0 +1,91 @@ + + + + + + + + +支付宝扫码支付 + + + +
+

+支付宝扫码支付 +

+
+
+
+
¥
+
+ +
+
+ + +
+
+ + +
+
+

请使用支付宝扫一扫

+

扫描二维码完成支付

+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/bank_qrcode.php b/doc/api/pages/bank_qrcode.php new file mode 100644 index 0000000..c569eac --- /dev/null +++ b/doc/api/pages/bank_qrcode.php @@ -0,0 +1,149 @@ + + + + + + + + +银联云闪付扫码支付 + + + +
+

+银联云闪付扫码支付 +

+
+
+
+
¥
+
+
+ +
+ + +
+
+ + +
+
+

请使用银联云闪付扫一扫

+

扫描二维码完成支付

+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/certok.php b/doc/api/pages/certok.php new file mode 100644 index 0000000..b697806 --- /dev/null +++ b/doc/api/pages/certok.php @@ -0,0 +1,79 @@ + + + + + + 实名认证成功 + + + + +
+
+
+
+ +
+
+

实名认证成功

+

请返回浏览器查看结果

+
+
+

+ 关闭 +

+
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/doc/api/pages/error.php b/doc/api/pages/error.php new file mode 100644 index 0000000..3cfce47 --- /dev/null +++ b/doc/api/pages/error.php @@ -0,0 +1,38 @@ + + + + + + 错误提示 + + + + +
+
+
+
+ +
+
+

错误提示

+

支付失败或支付超时,请返回重新发起支付

+
+
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/doc/api/pages/jdpay_qrcode.php b/doc/api/pages/jdpay_qrcode.php new file mode 100644 index 0000000..257994c --- /dev/null +++ b/doc/api/pages/jdpay_qrcode.php @@ -0,0 +1,108 @@ + + + + + + + + +京东扫码支付 + + + +
+

+京东扫码支付 +

+
+
+
+
¥
+
+
+ +
+ + +
+
+ + +
+
+

请使用京东APP扫一扫

+

扫描二维码完成支付

+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/jump.php b/doc/api/pages/jump.php new file mode 100644 index 0000000..7f8b76c --- /dev/null +++ b/doc/api/pages/jump.php @@ -0,0 +1,67 @@ + Safari打开'; +}elseif(strpos($useragent, 'micromessenger')!==false){ + $alert = ' 浏览器打开'; +}else{ + $alert = ' 浏览器打开'; +} +?> + + + + + 请使用浏览器打开 + + + + + + + + + +
+

点击右上角

+

可以继续浏览本站哦~

+
+
+ 您也可以复制本站网址,到其它浏览器打开 +
+点此继续访问 + + + + + + \ No newline at end of file diff --git a/doc/api/pages/ok.php b/doc/api/pages/ok.php new file mode 100644 index 0000000..1e075aa --- /dev/null +++ b/doc/api/pages/ok.php @@ -0,0 +1,73 @@ + + + + + + + 支付结果 + + + +
+
+
+
+
+

支付成功

+

支付成功,请回到浏览器查看订单

+
+
+

+ 关闭 + +

+
+
+
+
+ + + + diff --git a/doc/api/pages/openid.php b/doc/api/pages/openid.php new file mode 100644 index 0000000..4792dc0 --- /dev/null +++ b/doc/api/pages/openid.php @@ -0,0 +1,101 @@ + + + + + + 获取<?php echo $openid_name?> + + + + +
+
+
+
+ +
+
+

获取成功

+
+
+
+
如未自动填写,请手动复制下方
+
+
+
+ +
+
+
+
+
+ +
+ +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/qqpay_jspay.php b/doc/api/pages/qqpay_jspay.php new file mode 100644 index 0000000..a2ef714 --- /dev/null +++ b/doc/api/pages/qqpay_jspay.php @@ -0,0 +1,68 @@ + + + + + + + 支付宝支付 + + + +
+
+
+
+
+

正在跳转支付...

+
+
+
+
+ + + + +
+
+ + \ No newline at end of file diff --git a/doc/api/pages/qqpay_qrcode.php b/doc/api/pages/qqpay_qrcode.php new file mode 100644 index 0000000..9140926 --- /dev/null +++ b/doc/api/pages/qqpay_qrcode.php @@ -0,0 +1,112 @@ + + + + + + + + +QQ钱包扫码支付 + + + +
+

+QQ钱包扫码支付 +

+
+
+
+
¥
+
+
+ +
+ + +
+
+ + +
+
+

请使用手机QQ扫一扫

+

扫描二维码完成支付

+
+
+
+
+
+
+
+

手机用户可保存上方二维码到手机中

+

在手机QQ扫一扫中选择“相册”即可

+
+
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/qqpay_wap.php b/doc/api/pages/qqpay_wap.php new file mode 100644 index 0000000..68ef632 --- /dev/null +++ b/doc/api/pages/qqpay_wap.php @@ -0,0 +1,111 @@ + + + + + + + + + QQ钱包支付手机版 + + + + + +
+
+ +
+ + +
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/return.php b/doc/api/pages/return.php new file mode 100644 index 0000000..fd67c5d --- /dev/null +++ b/doc/api/pages/return.php @@ -0,0 +1,54 @@ + + + + + + + 支付结果 + + + +
+
+
+
+
+

正在检测付款结果...

+

稍后页面将自动跳转

+
+
+
+
+ + + + + diff --git a/doc/api/pages/verify_invisible.php b/doc/api/pages/verify_invisible.php new file mode 100644 index 0000000..ebb61d1 --- /dev/null +++ b/doc/api/pages/verify_invisible.php @@ -0,0 +1,75 @@ +'; +foreach ($query_arr as $k=>$v) { + $html.= ''; +} +$html .= ''; +?> + + + + + + 正在进行支付安全验证,请稍候... + + + +

正在进行支付安全验证,请稍候...

+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/verify_jump.php b/doc/api/pages/verify_jump.php new file mode 100644 index 0000000..f17f3f4 --- /dev/null +++ b/doc/api/pages/verify_jump.php @@ -0,0 +1,39 @@ +hieroglyphyString($key); + +$html = '
'; +foreach ($query_arr as $k=>$v) { + $html.= ''; +} +$html .= '
'; +?> + + + + + + 正在进行支付安全验证,请稍候... + + + +

正在进行支付安全验证,请稍候...

+ + + + \ No newline at end of file diff --git a/doc/api/pages/verify_slide.php b/doc/api/pages/verify_slide.php new file mode 100644 index 0000000..5eb554a --- /dev/null +++ b/doc/api/pages/verify_slide.php @@ -0,0 +1,90 @@ +'; +foreach ($query_arr as $k=>$v) { + $html.= ''; +} +$html .= ''; +?> + + + + + + 支付环境安全验证 + + + +
+
+

+ 很抱歉,当前支付人数过多,请完成“滑动验证”后继续支付 +

+
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/wxopen.php b/doc/api/pages/wxopen.php new file mode 100644 index 0000000..894a526 --- /dev/null +++ b/doc/api/pages/wxopen.php @@ -0,0 +1,72 @@ + + + + + 支付提示 + + + + + + + + +
+
+ 请在菜单中选择在浏览器中打开,
+ 以完成支付 +
+
+
+ + + + + \ No newline at end of file diff --git a/doc/api/pages/wxpay_h5.php b/doc/api/pages/wxpay_h5.php new file mode 100644 index 0000000..cfafa89 --- /dev/null +++ b/doc/api/pages/wxpay_h5.php @@ -0,0 +1,109 @@ + + + + + + + + + 微信支付手机版 + + + + + +
+
+ +
+ + +
+
+ + + + + + \ No newline at end of file diff --git a/doc/api/pages/wxpay_jspay.php b/doc/api/pages/wxpay_jspay.php new file mode 100644 index 0000000..cf424f6 --- /dev/null +++ b/doc/api/pages/wxpay_jspay.php @@ -0,0 +1,102 @@ + + + + + + +微信支付手机版 + + + +
+
+
+ ¥ + +
+
+
+ +
+
+ 商家 + 微信支付平台商户 +
+
+ +
+ + +
+ 支付安全由中国人民财产保险股份有限公司承保 +
+ + + +
+ + + \ No newline at end of file diff --git a/doc/api/pages/wxpay_qrcode.php b/doc/api/pages/wxpay_qrcode.php new file mode 100644 index 0000000..88f65d4 --- /dev/null +++ b/doc/api/pages/wxpay_qrcode.php @@ -0,0 +1,137 @@ + + + + + + + + +微信扫码支付 + + + +
+

+微信扫码支付 +

+
+
+
+ +
¥
+
+
+ +
+ + +
+
+ + +
+
+

请使用微信扫一扫

+

扫描二维码完成支付

+
+
+
+
+
+
+
+

手机用户可保存上方二维码到手机中

+

在微信扫一扫中选择“相册”即可

+
+
+
+ + + + + + + \ No newline at end of file diff --git a/doc/api/pages/wxpay_wap.php b/doc/api/pages/wxpay_wap.php new file mode 100644 index 0000000..6112886 --- /dev/null +++ b/doc/api/pages/wxpay_wap.php @@ -0,0 +1,145 @@ + + + + + + + + + 微信支付手机版 + + + + + +
+
+ +
+

¥

+
+ +
+
+
请使用微信APP扫描二维码支付
+
+ +
+
+ 二维码链接: + +
+
+ 提示:你可将以上二维码链接发到自己微信的聊天框(在微信顶部搜索框可以搜到自己的微信),点击即可进入支付! +
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..d106f40 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,64 @@ +# 架构与请求流 + +## 工作区结构 + +```text +MPAY_V2/ + admin/ 管理后台前端 + mer/ 商户后台前端 + cashier/ 收银台前端 + mpay/ Webman 后端 + docs/ 当前文档中心 +``` + +## 请求入口 + +```mermaid +flowchart LR + Admin["admin 前端"] --> AdminApi["/adminapi"] + Mer["mer 前端"] --> MerApi["/merapi"] + Cashier["cashier 前端"] --> CashierApi["/api/cashier"] + Merchant["商户系统 / SDK"] --> EpayV1["/submit.php / /mapi.php / /api.php"] + Merchant --> EpayV2["/api/pay / /api/merchant / /api/transfer"] + + AdminApi --> AdminRoute[app/route/admin.php] + MerApi --> MerRoute[app/route/mer.php] + CashierApi --> ApiRoute[app/route/api.php] + EpayV1 --> ApiRoute + EpayV2 --> ApiRoute + + AdminRoute --> Http[app/http] + MerRoute --> Http + ApiRoute --> Http + Http --> Service[app/service] + Service --> Repo[app/repository] + Repo --> Model[app/model] + Model --> DB[(MySQL)] + Service --> Redis[(Redis)] + Service --> Plugin["app/common/payment 插件"] + Plugin --> Channel["第三方支付通道"] +``` + +## 后端分层 + +| 层 | 目录 | 职责 | +| --- | --- | --- | +| 路由 | `app/route`、`config/route.php` | 绑定 URL、页面入口和中间件 | +| HTTP | `app/http` | 控制器、鉴权中间件、参数校验 | +| 服务 | `app/service` | 业务规则、状态流转、插件调用、通知、清算 | +| 仓库 | `app/repository` | 数据库读写封装 | +| 模型 | `app/model` | 表映射、类型转换、时间序列化 | +| 公共能力 | `app/common` | 基类、常量、工具、中间件、支付插件 | + +## 关键进程 + +- `webman`:HTTP 服务,监听 `0.0.0.0:8787`。 +- `payment-runtime`:支付运行时维护进程,负责商户通知重试、支付单超时扫描和支付中订单主动查单。 +- `monitor`:开发环境文件监控和自动重载。 + +## 关键约束 + +- `config/route.php` 显式加载 `admin.php`、`mer.php`、`api.php`,并关闭默认路由。 +- 管理后台和商户后台分别使用 `AdminAuthMiddleware`、`MerchantAuthMiddleware`。 +- CORS 由 `app/common/middleware/Cors` 处理。 +- 前端请求前缀在各自 `src/api/index.ts` 中集中拼接,不在页面里散写。 diff --git a/doc/backend/README.md b/doc/backend/README.md new file mode 100644 index 0000000..5cf5518 --- /dev/null +++ b/doc/backend/README.md @@ -0,0 +1,72 @@ +# 后端总说明 + +`mpay` 是支付中台后端服务,基于 Webman。命令默认在 `mpay/` 目录执行。 + +## 技术栈 + +- PHP `>=8.1` +- `workerman/webman-framework ^2.1` +- MySQL、Redis +- JWT、Webman validation/cache/event/redis/database +- OSS/COS SDK 用于对象存储 + +## 快速启动 + +```bash +composer install +Copy-Item .env.example .env +php webman start +``` + +Windows 开发环境如需启动自定义进程,可使用: + +```bash +php windows.php +``` + +## 主要目录 + +```text +app/ + command/ 命令与烟雾测试 + common/ 基类、常量、工具、中间件、支付插件 + http/ admin、mer、api 三类 HTTP 入口 + model/ 数据模型 + repository/ 数据访问 + route/ 显式路由 + service/ 业务服务 +config/ Webman 与业务配置 +public/ 静态资源与前端构建产物 +support/ Webman 支撑代码 +``` + +## 关键入口 + +- 路由:`config/route.php`、`app/route/admin.php`、`app/route/mer.php`、`app/route/api.php` +- 支付:`app/service/payment/order/PayOrderService.php` +- 退款:`app/service/payment/order/RefundService.php` +- 清算:`app/service/payment/settlement/SettlementService.php` +- 路由:`app/service/payment/runtime/PaymentRouteService.php` +- 插件:`app/service/payment/runtime/PaymentPluginManager.php` +- 商户:`app/service/merchant/MerchantService.php` +- 商户后台:`app/service/merchant/portal/MerchantPortalService.php` +- 文件:`app/service/file/FileRecordService.php` + +## 常用命令 + +```bash +php webman start +php webman restart +php webman mpay:test --all +php webman epay:mapi +php webman system:config-sync +``` + +## 关联文档 + +- [后端路由](./routing.md) +- [后端服务层](./services.md) +- [后端命令](./commands.md) +- [文件资产](./files.md) +- [接口总说明](../api/README.md) +- [部署说明](../deployment/backend.md) diff --git a/doc/backend/commands.md b/doc/backend/commands.md new file mode 100644 index 0000000..8e7e434 --- /dev/null +++ b/doc/backend/commands.md @@ -0,0 +1,41 @@ +# 后端命令 + +命令默认在 `mpay/` 目录执行。 + +## Webman 启动 + +| 命令 | 作用 | +| --- | --- | +| `php webman start` | 启动后端 HTTP 服务 | +| `php webman restart` | 重启后端服务 | +| `php windows.php` | Windows 开发环境启动 Webman 与自定义进程 | +| `php start.php start` | 生产/Linux 常见启动入口 | + +`payment-runtime` 是自定义进程,不是 Console 命令;它随 Webman 进程启动,负责通知重试、支付超时扫描和支付中订单主动查单。 + +## 业务命令 + +| 命令 | 作用 | +| --- | --- | +| `php webman mpay:test --all` | 支付、退款、清结算、余额、追踪链路烟雾测试 | +| `php webman epay:mapi` | ePay V1 `mapi.php` 兼容接口烟雾测试 | +| `php webman epay:v2-api` | ePay V2 创建、查询、关闭、退款、商户信息等核心 API 烟雾测试 | +| `php webman epay:mock-chain` | 自动写入 mock 配置并跑 ePay V1/V2 全链路 | +| `php webman epay:v2-bootstrap` | 生成开发联调用平台和商户 RSA 密钥 | +| `php webman payment:notify-retry` | 手动重试到期商户通知任务 | +| `php webman system:config-sync` | 将 `config/system_config.php` 默认配置同步到数据库 | +| `php webman test` | 基础命令注册检查 | + +## 常用参数 + +- `mpay:test`:`--payment`、`--refund`、`--settlement`、`--balance`、`--trace`、`--all`、`--live` +- `epay:mapi`:`--live`、`--merchant-id`、`--merchant-no`、`--type`、`--money`、`--refund-trade-no` +- `epay:v2-api`:`--live`、`--merchant-id`、`--merchant-no`、`--merchant-private-key-file`、`--type`、`--method`、`--money` +- `epay:mock-chain`:`--only=v1|v2|all` +- `payment:notify-retry`:`--limit` + +## 关联文档 + +- [后端总说明](./README.md) +- [支付运行时数据契约](./payment-runtime-contract.md) +- [ePay 兼容协议](../api/legacy/epay.md) diff --git a/doc/backend/compat.md b/doc/backend/compat.md new file mode 100644 index 0000000..6d8c940 --- /dev/null +++ b/doc/backend/compat.md @@ -0,0 +1,39 @@ +# ePay 兼容层 + +后端同时保留 ePay V1 旧入口和 ePay V2 新接口。兼容层只负责协议适配,不作为后台管理或商户后台的新能力入口。 + +## 当前实现 + +| 协议 | 控制器 | 服务 | +| --- | --- | --- | +| ePay V1 | `app/http/api/controller/epay/EpayV1Controller.php` | `app/service/payment/epay/EpayV1ProtocolService.php` | +| ePay V2 | `app/http/api/controller/epay/EpayV2Controller.php` | `app/service/payment/epay/EpayV2ProtocolService.php` | + +签名实现: + +- `Md5Signer` +- `RsaSigner` +- `EpaySignerManager` + +## V1 路由 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `ANY` | `/submit.php` | 页面跳转支付 | +| `POST` | `/mapi.php` | 接口支付 | +| `ANY` | `/api.php` | 旧版标准 API | + +## V2 路由 + +| 分组 | 路径 | +| --- | --- | +| 支付 | `/api/pay/submit`、`/api/pay/create`、`/api/pay/query`、`/api/pay/refund`、`/api/pay/refundquery`、`/api/pay/close`、`/api/pay/{payNo}/callback` | +| 商户 | `/api/merchant/info`、`/api/merchant/orders` | +| 转账 | `/api/transfer/submit`、`/api/transfer/query`、`/api/transfer/balance` | + +## 关联文档 + +- [接口总说明](../api/README.md) +- [收银台与开放接口](../api/cashier.md) +- [ePay 兼容协议](../api/legacy/epay.md) +- [后端路由](./routing.md) diff --git a/doc/backend/files.md b/doc/backend/files.md new file mode 100644 index 0000000..6a29649 --- /dev/null +++ b/doc/backend/files.md @@ -0,0 +1,46 @@ +# 文件资产 + +文件资产由后端记录元数据,实际文件交给本地、远程 URL、OSS 或 COS 驱动处理。 + +## 当前入口 + +- API 前缀:`/adminapi/file-asset` +- 控制器:`FileRecordController` +- 模型:`FileRecord` +- 数据表:`ma_file_asset` + +## 接口 + +| 方法 | 路径 | 作用 | +| --- | --- | --- | +| `GET` | `/file-asset/options` | 文件选项 | +| `GET` | `/file-asset` | 文件列表 | +| `POST` | `/file-asset/upload` | 上传文件 | +| `POST` | `/file-asset/import-remote` | 导入远程文件 | +| `GET` | `/file-asset/{id}/preview` | 文件预览 | +| `GET` | `/file-asset/{id}/download` | 文件下载 | +| `GET` | `/file-asset/{id}` | 文件详情 | +| `DELETE` | `/file-asset/{id}` | 删除文件 | + +## 服务与驱动 + +- `FileRecordService` +- `FileRecordQueryService` +- `FileRecordCommandService` +- `StorageConfigService` +- `StorageManager` +- `LocalStorageDriver` +- `RemoteUrlStorageDriver` +- `OssStorageDriver` +- `CosStorageDriver` + +## 字段口径 + +- `object_key`:站点相对路径或对象存储 key。 +- `url`:公开文件的完整访问地址,私有文件可以为空。 +- `preview_url`:后台预览使用,不作为长期业务访问地址。 +- `previewable`:列表返回给前端判断是否允许在线预览。 + +## 表单上传 + +系统配置和插件配置中的上传字段仍使用 `type: "upload"`。需要走项目定制选择器时,在 `props.fileUpload` 中声明 `selectorType`、`scene`、`isLocal`、`isPublic`、`getKey` 等配置。前端说明见 [管理后台前端](../frontend/admin.md)。 diff --git a/doc/backend/payment-plugin-template.md b/doc/backend/payment-plugin-template.md new file mode 100644 index 0000000..3dbb6e5 --- /dev/null +++ b/doc/backend/payment-plugin-template.md @@ -0,0 +1,261 @@ +# 支付插件模板说明 + +这份说明对应 `mpay/app/common/payment/TemplatePayment.php`。 + +它的作用不是提供一个可直接上线的插件,而是提供一份可以复制、改名、接第三方网关的起点模板。 + +## 1. 这个模板解决什么问题 + +支付插件开发最容易踩坑的地方,不是第三方接口调用本身,而是这些基础约定容易不统一: + +- 插件元信息怎么写 +- 配置表单怎么定义 +- `class_name` 怎么配置 +- `pay()` 返回什么结构 +- 回调怎么收口 +- 哪些字段该放在插件配置里,哪些字段该放在订单入参里 + +`TemplatePayment` 把这些骨架先搭好,后面新增插件时可以直接复制,再替换为真实渠道逻辑。 + +## 2. 模板在系统里怎么被加载 + +插件并不是直接被业务代码 `new` 出来的,而是先经过插件注册表,再由工厂服务实例化。 + +```mermaid +flowchart LR + A[后台维护插件定义] --> B[ma_payment_plugin] + B --> C[ma_payment_plugin_conf] + C --> D[ma_payment_channel] + D --> E[PaymentPluginFactoryService] + E --> F[实例化插件类] + F --> G[init(配置)] + G --> H[pay / query / close / refund / notify] +``` + +关键点有两个: + +- `ma_payment_plugin.class_name` 可以填短类名,也可以填完整类名 +- 如果是短类名,工厂会自动补成 `app\\common\\payment\\{class_name}` + +也就是说,`TemplatePayment` 这种类既可以直接写成 `TemplatePayment`,也可以写成完整命名空间。 + +## 3. 复制时要改哪些地方 + +复制这个模板后,优先改下面几块: + +1. `paymentInfo.code` +2. `paymentInfo.name` +3. `paymentInfo.pay_types` +4. `paymentInfo.transfer_types` +5. `paymentInfo.config_schema` +6. `init()` +7. `pay()` +8. `query()` +9. `close()` +10. `refund()` +11. `notify()` + +其中最重要的是: + +- `paymentInfo` 决定后台怎么展示这个插件 +- `config_schema` 决定后台怎么维护插件配置 +- `init()` 决定插件怎么吃到 `ma_payment_plugin_conf.config` 里的运行时参数 +- `pay()` 决定第三方下单时返回给系统什么支付参数 +- `notify()` 决定第三方回调回来后怎么验签和归一化结果 + +## 4. 这个模板里的返回结构 + +### 4.1 `pay()` 的返回值 + +模板已经按项目当前口径返回了这几个字段: + +- `pay_product` +- `pay_action` +- `pay_params` +- `chan_order_no` +- `chan_trade_no` +- `ext_json` + +其中 `pay_params` 是给收银台前端或业务调用方用的,常见类型包括: + +- `html` +- `qrcode` +- `jump` +- `h5` +- `jsapi` +- `urlscheme` +- `mini` +- `json` +- `error` + +后端会在支付单拉起后立即校验这份返回值。校验失败会把支付单收口为失败态并抛出 `PaymentException`,所以新插件不要返回旧字段名或半结构化内容,必须直接返回标准结构。 + +不同 `pay_params.type` 的必要字段: + +- `jump` / `web` / `h5`:`redirect_url`、`payurl`、`pay_url`、`mweb_url` 或 `url` +- `qrcode`:`qrcode_text`、`qrcode_data`、`qrcode_url` 或 `qrcode` +- `html`:`html` 或 `action` +- `jsapi`:`jsapi_params`,或 `order_str` / `order_string` 等拉起参数 +- `urlscheme`:`urlscheme`、`redirect_url`、`order_str` 或 `order_string` +- `mini`:`path`、`scheme`、`urlscheme`、`trade_no` 或 `mini_params` + +实际接第三方时,你只需要把 `pay_params` 换成真实可渲染的数据结构即可。`ext_json` 只能放插件私有的轻量补充信息,完整请求、响应和通知记录不要放在这里。 + +标准示例: + +```php +[ + 'pay_product' => 'alipay', + 'pay_action' => 'jump', + 'pay_params' => [ + 'type' => 'jump', + 'redirect_url' => 'https://...', + ], + 'chan_order_no' => '渠道订单号', + 'chan_trade_no' => '渠道交易号,可选;未生成时返回空字符串', + 'ext_json' => [], +] +``` + +### 4.2 `query()` 的返回值 + +主动查单依赖插件 `query()`。新插件建议直接返回下面的标准结构,便于定时维护进程统一推进订单状态: + +```php +[ + 'success' => true, + 'status' => 'success|failed|closed|pending', + 'channel_order_no' => '渠道订单号', + 'channel_trade_no' => '渠道交易号', + 'channel_status' => '渠道原始状态', + 'message' => '查询说明,可选', + 'paid_at' => '2026-04-25 12:00:00', + 'failed_at' => null, + 'ext_json' => [], +] +``` + +`status=success` 会推进支付成功,`failed` 会推进失败,`closed` 会推进关闭。`pending`、`unknown` 或查询异常只记录轻量查单快照,不改变支付单终态。 + +### 4.3 `notify()` 的返回值 + +回调处理建议返回这些语义字段: + +- `status` +- `message` +- `channel_order_no` +- `channel_trade_no` +- `channel_status` +- `paid_at` +- `failed_at` +- `fee_actual_amount` +- `ext_json` + +如果是失败回调,也可以补充: + +- `channel_error_code` +- `channel_error_msg` + +后端回调链路会根据 `status` 统一推进支付单状态。完整回调原文和插件解析结果会保存到 `ma_pay_callback_log`,不要再塞进支付单 `ext_json`。 + +标准示例: + +```php +[ + 'status' => 'success', + 'message' => 'TRADE_SUCCESS', + 'channel_order_no' => '渠道订单号', + 'channel_trade_no' => '渠道交易号', + 'channel_status' => 'TRADE_SUCCESS', + 'paid_at' => '2026-04-25 12:00:00', + 'fee_actual_amount' => null, + 'ext_json' => [], +] +``` + +### 4.4 订单扩展字段 + +业务单和支付单的 `ext_json` 使用分区结构: + +- 顶层 `_protocol_version`:协议版本,方便后台查询。 +- `merchant`:商户透传字段,如 `param`、`buyer`。 +- `payment`:本次支付载体字段,如 `method`、`auth_code`、`sub_openid`。 +- `presentation`:插件返回给收银台承接的支付参数快照。 +- `plugin`:插件私有轻量信息,以及主动查单的 `active_query` 快照。 +- `lifecycle`:关单、超时等生命周期原因。 + +详细契约见 [支付运行时数据契约](./payment-runtime-contract.md)。 + +## 5. 模板里的占位逻辑 + +`TemplatePayment` 里有意保留了一些占位实现: + +- `query()` +- `close()` +- `refund()` +- `notify()` + +这些方法现在会直接抛出 `PaymentException`,避免被误当成真实插件投入使用。 + +`pay()` 里也保留了示例结构: + +- 默认支付形态可选 `html`、`qrcode`、`jump`、`jsapi` +- `buildAutoSubmitForm()` 只是表单跳转的通用模板 +- `sign` 目前是 `TODO` + +所以它更像“开发脚手架”,不是上线成品。 + +## 6. 推荐的开发步骤 + +建议按这个顺序做新插件: + +1. 复制 `TemplatePayment.php`,改成新的类名 +2. 修改 `paymentInfo`,让插件代码和后台展示名称唯一 +3. 根据第三方网关要求补齐 `config_schema` +4. 在 `init()` 里读取配置、初始化 SDK +5. 在 `pay()` 里实现真实下单 +6. 在 `notify()` 里实现真实回调验签 +7. 把 `query()`、`close()`、`refund()` 按第三方能力补齐 +8. 在后台创建 `ma_payment_plugin` 记录 +9. 再创建对应的 `ma_payment_plugin_conf` 记录 +10. 把通道的 `plugin_code` 和 `api_config_id` 绑定起来 + +## 7. 后台配置关系 + +从数据库角度看,通常会涉及三张表: + +- `ma_payment_plugin`:插件注册表,保存 `code`、`name`、`class_name`、`config_schema`、`pay_types` +- `ma_payment_plugin_conf`:插件运行配置表,保存 `config` +- `ma_payment_channel`:支付通道表,保存 `plugin_code` 和 `api_config_id` + +也就是说: + +- `ma_payment_plugin` 负责“这个插件是什么” +- `ma_payment_plugin_conf` 负责“这个插件怎么运行” +- `ma_payment_channel` 负责“这个插件被哪个通道使用” + +## 8. 适合复制的场景 + +这个模板特别适合下面几类插件: + +- 表单跳转类支付 +- 二维码类支付 +- 链接跳转类支付 +- 需要自定义回调验签的第三方网关 +- 先接通主链路、后逐步补齐查单退款的渠道 + +如果是像支付宝这种已经有成熟 SDK 的渠道,也可以直接参考现有 `AlipayPayment` 的实现,再用模板做新的落地版本。 + +## 9. 使用建议 + +- 新插件先保证 `pay()` 和 `notify()` 跑通,再补 `query()`、`refund()`、`close()` +- 插件配置只放运行时必需信息,不要把订单级入参混进去 +- `pay_types` 存的是支付方式编码,不是支付方式 ID +- 新插件类名和 `code` 一定要唯一,避免和已有插件冲突 +- 真正上线前,必须把占位异常和 `TODO` 字段全部替换掉 + +## 10. 相关代码 + +- `mpay/app/common/payment/TemplatePayment.php` +- `mpay/app/service/payment/runtime/PaymentPluginFactoryService.php` +- `docs/db/payment-middle-ddl.sql` diff --git a/doc/backend/payment-runtime-contract.md b/doc/backend/payment-runtime-contract.md new file mode 100644 index 0000000..cf0bdea --- /dev/null +++ b/doc/backend/payment-runtime-contract.md @@ -0,0 +1,243 @@ +# 支付运行时数据契约 + +本文档约定支付链路里订单扩展字段、插件入参和插件返回值的结构。当前项目仍处于开发阶段,不保留旧平铺结构兼容。 + +## 1. `ext_json` 职责 + +`ext_json` 只保存单据恢复、页面承接、插件运行所需的轻量扩展信息。通知、回调、重试、原始报文不进入 `ext_json`。 + +专门表职责如下: + +- `ma_pay_callback_log`:保存每一次渠道回调原始参数、请求摘要、验签状态、处理状态、插件解析结果。 +- `ma_notify_task`:按 `event_type + ref_no` 保存商户通知内容、通知状态、重试次数、最后响应。 +- `ma_channel_notify_log`:保存渠道通知或查单类日志。 +- `ma_pay_order`:保存支付尝试当前状态、渠道单号、回调状态、错误码和页面承接快照。 + +## 2. `ma_biz_order.ext_json` + +业务单只保存稳定业务上下文。同一商户订单号复用时会校验这些字段,支付载体参数不得放在业务单里。 + +```php +[ + '_protocol_version' => 'v1', // 顶层强语义字段,方便后台查询和排障 + 'merchant' => [ + 'param' => '商户透传参数', + 'buyer' => '商户侧买家标识,可选', + ], +] +``` + +## 3. `ma_pay_order.ext_json` + +支付单是一笔支付尝试的快照,可以保存本次支付载体和页面承接信息。 + +```php +[ + '_protocol_version' => 'v2', + 'merchant' => [ + 'param' => '商户透传参数', + 'buyer' => '商户侧买家标识,可选', + ], + 'payment' => [ + 'method' => 'web|jump|jsapi|app|scan|applet', + 'auth_code' => '条码/付款码支付时的付款码', + 'sub_openid' => 'JSAPI 支付所需 openid', + 'sub_appid' => '服务商模式子应用 ID', + ], + 'presentation' => [ + 'params_type' => 'jump|qrcode|html|jsapi|urlscheme|json|error', + 'product' => 'alipay|wxpay|unionpay|...', + 'action' => '插件动作名', + 'params_snapshot' => [ + 'type' => 'qrcode', + 'qrcode_url' => 'https://...', + ], + ], + 'plugin' => [ + 'pay_result' => [], + 'close_result' => [], + 'active_query' => [ + 'queried_at' => '2026-04-25 12:00:00', + 'status' => 'success|failed|closed|pending|unknown|error', + 'raw_status' => '渠道原始状态', + 'channel_status' => '渠道状态码', + 'message' => '查单说明或异常信息', + 'success' => true, + 'channel_order_no' => '渠道订单号', + 'channel_trade_no' => '渠道交易号', + 'query_count' => 1, + ], + ], + 'lifecycle' => [ + 'close_reason' => '关闭原因', + 'timeout_reason' => '超时原因', + ], +] +``` + +## 4. 插件 `pay()` 入参 + +系统调用插件下单时会传入结构化 `extra`。插件读取商户透传、支付载体或协议字段时,从对应分区取值。 + +```php +[ + 'pay_no' => 'PAY...', + 'biz_no' => 'BIZ...', + 'channel_request_no' => 'REQ...', + 'merchant_id' => 1, + 'merchant_no' => 'M...', + 'pay_type_code' => 'alipay', + 'amount' => 100, + 'subject' => '订单标题', + 'callback_url' => 'https://platform/api/pay/PAY.../callback', + 'notify_url' => 'https://merchant/notify', + 'return_url' => 'https://merchant/return', + 'client_ip' => '127.0.0.1', + '_env' => 'pc', + 'extra' => [ + '_protocol_version' => 'v2', + 'merchant' => [], + 'payment' => [], + ], +] +``` + +## 5. 插件 `pay()` 返回值 + +插件下单必须返回系统可承接的标准结构。后端会在 `PayOrderChannelDispatchService` 中严格校验字段和 `pay_params.type` 所需载荷,校验通过后才会写入 `ma_pay_order.ext_json.presentation`。 + +```php +[ + 'pay_product' => 'alipay', + 'pay_action' => 'jump', + 'pay_params' => [ + 'type' => 'jump', + 'redirect_url' => 'https://...', + ], + 'chan_order_no' => '渠道订单号', + 'chan_trade_no' => '渠道交易号,可选;未生成时返回空字符串', + 'ext_json' => [ + // 插件私有轻量信息,可选 + ], +] +``` + +`pay_params.type` 决定收银台如何承接: + +- `jump` / `web` / `h5`:必须提供 `redirect_url`、`payurl`、`pay_url`、`mweb_url` 或 `url`。 +- `qrcode`:必须提供 `qrcode_text`、`qrcode_data`、`qrcode_url` 或 `qrcode`。 +- `html` / `form`:必须提供 `html` 或 `action`;`form` 会归一为 `html`。 +- `jsapi` / `urlscheme` / `mini`:必须提供对应拉起参数、跳转参数或小程序参数。 +- `pos` / `transfer`:展示结构化参数,适合收银设备或转账类场景。 +- `json`:直接展示结构化参数,由业务端继续处理。 +- `error`:展示插件返回的错误信息。 + +`pay_params.type` 的兼容别名只在平台内部归一化使用,插件文档和新插件代码应直接返回标准值。常见别名包括:`scan|qr|code -> qrcode`、`redirect|url -> jump`、`wap -> h5`、`form -> html`、`app -> urlscheme`、`applet|wxplugin -> mini`。 + +## 6. 插件 `query()` 返回值 + +主动查单由 `PaymentRuntimeProcess` 定时触发,只处理 `status=支付中` 且已超过最小等待时间的支付单。插件 `query()` 应尽量返回标准字段: + +```php +[ + 'success' => true, + 'status' => 'success|failed|closed|pending', + 'channel_order_no' => '渠道订单号', + 'channel_trade_no' => '渠道交易号', + 'channel_status' => 'TRADE_SUCCESS', + 'message' => '渠道说明,可选', + 'paid_at' => '2026-04-25 12:00:00', + 'failed_at' => null, + 'ext_json' => [ + // 插件私有轻量补充信息,可选 + ], +] +``` + +处理规则: + +- `success`:推进支付单成功,并发出 `payment.pay_order.succeeded` 事件。 +- `failed`:推进支付单失败。 +- `closed`:推进支付单关闭。 +- `pending` / `unknown` / 查单异常:不推进终态,只把轻量快照写入 `ma_pay_order.ext_json.plugin.active_query`。 + +主动查单快照只保存状态、说明、渠道单号、时间和次数,不保存完整上游响应;完整回调仍以 `ma_pay_callback_log` 为准。 + +## 7. 插件 `notify()` 返回值 + +插件回调只负责验签和归一化结果。完整回调原文和该返回值会写入 `ma_pay_callback_log`,不会再回灌支付单 `ext_json`。 + +```php +[ + 'status' => 'success|failed|pending', + 'message' => '渠道状态说明', + 'channel_order_no' => '渠道订单号', + 'channel_trade_no' => '渠道交易号', + 'channel_status' => 'TRADE_SUCCESS', + 'channel_error_code' => '', + 'channel_error_msg' => '', + 'paid_at' => '2026-04-25 12:00:00', + 'failed_at' => null, + 'fee_actual_amount' => null, + 'ext_json' => [ + // 插件私有轻量补充信息,可选 + ], +] +``` + +`status=pending` 只记录回调日志,不推进支付单终态。`success` 和 `failed` 会推进支付单状态,并按需要创建商户通知任务。 + +## 8. 商户通知任务事件模型 + +`ma_notify_task` 不再以 `pay_no` 作为唯一业务键。通知任务统一按事件建模: + +```php +[ + 'event_type' => 'PAY_SUCCESS|REFUND_SUCCESS|SETTLEMENT_SUCCESS', + 'ref_no' => '事件引用单号,例如 pay_no/refund_no/settle_no', + 'biz_no' => '业务单号', + 'pay_no' => '支付单号,支付相关事件保留', + 'notify_data' => [ + // 发送给商户的已签名参数 + ], +] +``` + +这样同一笔支付后续可以同时存在支付成功、退款成功、清算完成等多类通知,不会因为复用 `pay_no` 被唯一键挡住。 + +## 9. 回调日志留痕规则 + +渠道回调日志按“每次请求一条”写入,不做 `pay_no + callback_type` 覆盖或复用。 + +`request_hash` 是原始请求载荷的 SHA-256 摘要,用来在后台识别重复通知;重复通知是否推进业务状态,由支付单生命周期幂等控制。 + +## 10. 支付运行时维护 + +运行时维护使用 Webman 自定义进程 `payment-runtime`,对应 `app/process/PaymentRuntimeProcess.php`。进程只负责定时调度,具体业务由 `PaymentRuntimeMaintenanceService` 完成。 + +当前维护任务: + +- 商户通知重试:扫描到期 `ma_notify_task` 并重新派发。 +- 支付单超时:扫描已过期且未终态的支付单,推进为超时并释放冻结手续费。 +- 主动查单:扫描支付中订单,调用插件 `query()` 补偿异步通知丢失或延迟。 + +当前阶段采用自定义进程直接执行,代码路径更短、部署依赖更少。后续如果商户通知量明显增大,建议引入 Redis 队列:支付成功监听器只负责投递队列,队列消费者负责 HTTP 通知派发和重试。 + +可在管理后台“支付配置”中维护以下系统配置: + +- `pay_runtime_enabled`:运行时维护总开关。 +- `pay_notify_retry_scan_interval_seconds` / `pay_notify_retry_batch_size`:通知重试扫描间隔和批量。 +- `pay_order_timeout_scan_interval_seconds` / `pay_order_timeout_batch_size`:超时订单扫描间隔和批量。 +- `pay_active_query_enabled` / `pay_active_query_interval_seconds` / `pay_active_query_min_age_seconds` / `pay_active_query_batch_size`:主动查单开关、间隔、等待时间和批量。 + +系统配置保存后会触发 `system.config.changed` 事件刷新运行时缓存,维护进程下一轮心跳读取新值。 + +## 11. 支付域事件 + +支付生命周期服务只负责状态推进和资金动作。支付单首次进入成功态后,会发送事件: + +```php +PaymentEventConstant::PAY_ORDER_SUCCEEDED // payment.pay_order.succeeded +``` + +当前监听器 `PayOrderSucceededListener` 负责创建并派发商户支付成功通知。后续如果引入 Redis 队列,可以只替换监听器内部实现,把通知派发入队,而不用改订单生命周期服务。 diff --git a/doc/backend/routing.md b/doc/backend/routing.md new file mode 100644 index 0000000..1024652 --- /dev/null +++ b/doc/backend/routing.md @@ -0,0 +1,47 @@ +# 后端路由 + +后端只使用显式路由。`config/route.php` 加载三份路由文件后关闭默认路由。 + +## 路由文件 + +| 文件 | 覆盖范围 | +| --- | --- | +| `app/route/admin.php` | `/admin` 页面入口、`/adminapi` 管理后台 API | +| `app/route/mer.php` | `/mer` 页面入口、`/merapi` 商户后台 API | +| `app/route/api.php` | 收银台页面、收银台 API、ePay V1/V2 与开放 API | + +## 当前入口 + +| 前缀 | 说明 | 中间件 | +| --- | --- | --- | +| `/admin` | 管理后台静态页面入口 | 无业务鉴权 | +| `/adminapi` | 管理后台接口 | `Cors`,保护接口再走 `AdminAuthMiddleware` | +| `/mer` | 商户后台静态页面入口 | 无业务鉴权 | +| `/merapi` | 商户后台接口 | `Cors`,保护接口再走 `MerchantAuthMiddleware` | +| `/cashier` | 收银台入口页 | 无业务鉴权 | +| `/payment` | 支付页、中转页、结果页 | 无业务鉴权 | +| `/api/cashier` | 收银台上下文、确认支付、支付单详情 | `Cors` | +| `/api/pay` | ePay V2 支付、查询、退款、关闭、通道回调 | `Cors` | +| `/api/merchant` | ePay V2 商户信息与订单查询 | `Cors` | +| `/api/transfer` | ePay V2 转账提交、查询、余额 | `Cors` | +| `/submit.php`、`/mapi.php`、`/api.php` | ePay V1 兼容入口 | `Cors` | + +## 流转 + +```mermaid +flowchart LR + Req[HTTP 请求] --> Route[显式路由] + Route --> Middleware[中间件] + Middleware --> Controller[控制器] + Controller --> Validator[参数校验] + Validator --> Service[服务层] + Service --> Repository[仓库层] + Repository --> Model[模型层] + Model --> DB[(MySQL)] +``` + +## 维护要求 + +- 新增接口先改 `app/route/*`,再补对应 `docs/api/*`。 +- 页面兜底路由只返回前端入口,不承载业务逻辑。 +- 业务规则放服务层;路由文件只做 URL 到控制器方法的绑定。 diff --git a/doc/backend/services.md b/doc/backend/services.md new file mode 100644 index 0000000..b81c17d --- /dev/null +++ b/doc/backend/services.md @@ -0,0 +1,51 @@ +# 后端服务层 + +服务层承载业务规则,控制器只负责入口、参数和响应包装。 + +## 目录职责 + +| 目录 | 主要职责 | +| --- | --- | +| `account/funds`、`account/ledger` | 商户账户、余额和资金流水 | +| `bootstrap` | 启动期初始化 | +| `file`、`file/storage` | 文件资产、上传、导入、预览、下载和存储驱动 | +| `merchant` | 商户主体、总览、分组、策略、API 凭证 | +| `merchant/auth` | 商户后台登录认证 | +| `merchant/portal` | 商户后台资料、通道、路由预览、订单资金查询 | +| `ops/log`、`ops/stat` | 通道通知、支付回调、商户通知任务和通道日统计 | +| `payment/cashier` | 收银台上下文、确认支付、支付单展示 | +| `payment/config` | 支付方式、插件、插件配置、通道、轮询组、绑定 | +| `payment/epay` | ePay V1/V2 协议、MD5/RSA 签名 | +| `payment/order` | 支付单、退款单、费用、派单、回调、生命周期 | +| `payment/runtime` | 路由解析、插件装配、商户通知、运行时维护 | +| `payment/settlement` | 清算单和清算生命周期 | +| `payment/trace` | 交易追踪与报表 | +| `payment/transfer` | 转账能力 | +| `system/access`、`system/config`、`system/user` | 管理员认证、系统配置、管理员用户 | + +## 命名规则 + +- `*Service`:对外门面。 +- `*QueryService`:查询与展示拼装。 +- `*CommandService`:写入、修改、删除。 +- `*LifecycleService`:状态流转。 +- `*CallbackService`:第三方回调。 +- `*SyncService`:同步、扫描、刷新。 +- `*SupportService`:业务辅助能力。 + +## 关键服务 + +- 支付:`PayOrderService`、`PayOrderLifecycleService`、`PayOrderChannelDispatchService` +- 退款:`RefundService`、`RefundCreationService`、`RefundLifecycleService` +- 清算:`SettlementService`、`SettlementLifecycleService` +- 路由与插件:`PaymentRouteService`、`PaymentRouteResolverService`、`PaymentPluginManager` +- 收银台:`CashierService` +- 通知:`NotifyService`、`MerchantNotifyDispatcherService` +- 商户:`MerchantService`、`MerchantPortalService`、`MerchantApiCredentialService` +- 文件:`FileRecordService`、`StorageManager` + +## 维护要求 + +- 查询和写入不要混在一个越来越胖的类里。 +- 回调、路由解析、插件装配、通知重试要保持独立职责。 +- `SupportService` 只放有业务含义的复用逻辑,不做基础工具的空转发。 diff --git a/doc/changelog.md b/doc/changelog.md new file mode 100644 index 0000000..8d65012 --- /dev/null +++ b/doc/changelog.md @@ -0,0 +1,22 @@ +# 变更记录 + +## 2026-04-26 + +- 重新核对 `mpay/app/route`、前端 `src/api`、`package.json`、`composer.json` 后整理文档。 +- 修正收银台真实入口为 `/cashier`、`/payment`,收银台 JSON API 为 `/api/cashier/*`。 +- 修正 ePay V1/V2 控制器、服务、命令和开放 API 路由说明。 +- 精简 `docs/` 中重复的写法建议和过细示例,保留入口、模块、命令、变量和关键业务口径。 +- 更新 `admin`、`mer`、`cashier`、`mpay` 项目 README,使命令和路径与当前代码一致。 + +## 2026-04-20 + +- 补充项目注释与封装边界说明。 +- 统一商户 API 凭证相关文案。 +- 补充管理后台上传字段和文件资产说明。 + +## 2026-04-17 + +- 建立 `docs/` 作为新的总文档中心。 +- 将根目录定位为多项目工作区入口。 +- 补齐总览、架构、后端、前端、接口、数据库、部署和 FAQ 文档。 +- 将旧版后端文档降级为归档参考。 diff --git a/doc/db/README.md b/doc/db/README.md new file mode 100644 index 0000000..19375f8 --- /dev/null +++ b/doc/db/README.md @@ -0,0 +1,61 @@ +# 数据库总说明 + +这里放数据库相关的稳定说明和 DDL 入口。 + +## 当前 DDL + +当前数据库事实源已经迁移到 [`payment-middle-ddl.sql`](./payment-middle-ddl.sql)。 + +它覆盖的核心表分组包括: + +- 商户与登录主体 +- 支付方式、支付插件、支付通道 +- 轮询组、轮询组通道、轮询组绑定 +- 商户策略与商户 API 凭证 +- 支付单、退款单、转账单、清算单 +- 商户资金账户与流水 +- 通知日志、回调日志、日统计 +- 文件资产、系统配置、后台用户 + +## 关键表分组 + +| 分组 | 代表表 | +| --- | --- | +| 基础字典 | `ma_payment_type`、`ma_system_config` | +| 商户主体 | `ma_merchant`、`ma_merchant_group`、`ma_merchant_api_credential`、`ma_merchant_policy` | +| 支付编排 | `ma_payment_plugin`、`ma_payment_plugin_conf`、`ma_payment_channel`、`ma_payment_poll_group`、`ma_payment_poll_group_channel`、`ma_payment_poll_group_bind` | +| 交易订单 | `ma_biz_order`、`ma_pay_order`、`ma_refund_order`、`ma_transfer_order`、`ma_settlement_order`、`ma_settlement_item` | +| 资金账户 | `ma_merchant_account`、`ma_merchant_account_ledger` | +| 运维日志 | `ma_channel_notify_log`、`ma_pay_callback_log`、`ma_channel_daily_stat`、`ma_notify_task` | +| 文件与后台 | `ma_file_asset`、`ma_admin_user` | + +## 说明原则 + +- DDL 是事实源 +- 表结构变化要先更新 DDL,再补说明 +- 文档里不要重复贴大段 SQL,尽量只解释结构和用途 +- `ma_merchant` 是商户主体,也是后台登录主体 +- `ma_merchant_api_credential` 只用于开放接口签名和兼容层,不参与后台登录 +- `ma_merchant_api_credential` 同时承载 V1 的 MD5 凭证值和 V2 的 RSA 公钥 +- `ma_transfer_order` 负责承接 V2 转账单据 +- 路由链路优先遵循“商户分组 -> 轮询组 -> 支付通道” +- `ext_json` 使用分区结构保存轻量运行上下文;`_protocol_version` 这类强语义字段可放顶层,商户透传放 `merchant`,支付载体放 `payment`,收银台承接放 `presentation` +- 通知、回调、重试、原始报文进入 `ma_pay_callback_log`、`ma_notify_task`、`ma_channel_notify_log`,不要塞进订单扩展槽 +- `ma_pay_order` 不再保留 `request_method` 这类 HTTP 快照字段 + +## 建议写法 + +以后每个表说明都按下面的结构写: + +1. 表用途 +2. 主键与索引 +3. 核心字段 +4. 状态值含义 +5. 与哪些业务链路相关 + +## 继续阅读 + +- [数据表目录](./tables.md) +- [项目稳定口径](../standards.md) +- [项目总览](../overview.md) +- [架构与请求流](../architecture.md) diff --git a/doc/db/payment-middle-ddl.sql b/doc/db/payment-middle-ddl.sql new file mode 100644 index 0000000..bec62e6 --- /dev/null +++ b/doc/db/payment-middle-ddl.sql @@ -0,0 +1,609 @@ +-- 支付中台最新总 DDL +-- 说明: +-- 1. 本文件汇总当前最新表结构,作为后续 Navicat 调整和代码对齐的参考基线。 +-- 2. 当前以 `ma_merchant` 为商户主体和后台登录主体,登录口径是 `merchant_no + password_hash`。 +-- 3. `ma_merchant_api_credential` 仅用于开放接口签名与兼容层,不参与商户后台登录。 +-- 4. 已删除 `channel_code` 业务编码,所有关联一律使用主键 `id`。 +-- 5. 路由链路采用:商户分组 -> 轮询组 -> 支付通道。 +-- 6. `ma_merchant_policy` 当前仅保留预留表结构,不作为正式后台能力暴露。 +-- 7. 所有状态、模式、类型类字段统一使用 `tinyint`,语义映射请见 `app/common/constant`。 +-- 8. `ext_json` 使用分区结构保存轻量运行上下文;`_protocol_version` 可放顶层便于查询,通知/回调/重试/原始报文进入专门日志表。 +-- 9. `ma_pay_order` 不再保留 `request_method` 这类 HTTP 快照字段。 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE IF NOT EXISTS `ma_payment_type` ( + `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付方式编码', + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '支付方式名称', + `icon` varchar(255) NOT NULL DEFAULT '' COMMENT '图标', + `sort_no` int NOT NULL DEFAULT 0 COMMENT '排序', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_status_sort` (`status`, `sort_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付方式字典表'; + +CREATE TABLE IF NOT EXISTS `ma_merchant_group` ( + `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `group_name` varchar(100) NOT NULL DEFAULT '' COMMENT '商户分组名称', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_group_name` (`group_name`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户分组表'; + +CREATE TABLE IF NOT EXISTS `ma_merchant` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_no` varchar(32) NOT NULL DEFAULT '' COMMENT '商户号(唯一,对外标识,同时作为后台登录账号)', + `password_hash` varchar(255) NOT NULL DEFAULT '' COMMENT '商户后台登录密码哈希', + `merchant_name` varchar(100) NOT NULL DEFAULT '' COMMENT '商户名称', + `merchant_short_name` varchar(60) NOT NULL DEFAULT '' COMMENT '商户简称', + `merchant_type` tinyint NOT NULL DEFAULT 0 COMMENT '商户类型:0-个人,1-企业,2-其他', + `group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID', + `risk_level` tinyint NOT NULL DEFAULT 0 COMMENT '风控等级:0-低,1-中,2-高', + `contact_name` varchar(50) NOT NULL DEFAULT '' COMMENT '联系人姓名', + `contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '联系人手机号', + `contact_email` varchar(100) NOT NULL DEFAULT '' COMMENT '联系人邮箱', + `settlement_account_name` varchar(100) NOT NULL DEFAULT '' COMMENT '结算账户名', + `settlement_account_no` varchar(100) NOT NULL DEFAULT '' COMMENT '结算账户号', + `settlement_bank_name` varchar(100) NOT NULL DEFAULT '' COMMENT '结算银行名称', + `settlement_bank_branch` varchar(100) NOT NULL DEFAULT '' COMMENT '结算支行名称', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `pay_status` tinyint NOT NULL DEFAULT 1 COMMENT '支付开关:0-关闭,1-开启', + `settle_status` tinyint NOT NULL DEFAULT 1 COMMENT '结算开关:0-关闭,1-开启', + `settle_type` tinyint NOT NULL DEFAULT 4 COMMENT '结算类型:1-支付宝,2-微信,3-QQ,4-银行卡', + `last_login_at` datetime DEFAULT NULL COMMENT '最后登录时间', + `last_login_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '最后登录IP', + `password_updated_at` datetime DEFAULT NULL COMMENT '密码更新时间', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_merchant_no` (`merchant_no`), + KEY `idx_group_id` (`group_id`), + KEY `idx_contact_phone` (`contact_phone`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户表'; + +CREATE TABLE IF NOT EXISTS `ma_payment_plugin` ( + `code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码', + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '插件名称', + `class_name` varchar(255) NOT NULL DEFAULT '' COMMENT '插件类名', + `config_schema` json DEFAULT NULL COMMENT '插件配置Schema(JSON)', + `pay_types` json DEFAULT NULL COMMENT '支持的支付方式编码(JSON)', + `transfer_types` json DEFAULT NULL COMMENT '支持的转账方式编码(JSON)', + `version` varchar(20) NOT NULL DEFAULT '' COMMENT '版本', + `author` varchar(50) NOT NULL DEFAULT '' COMMENT '作者', + `link` varchar(255) NOT NULL DEFAULT '' COMMENT '链接', + `allow_merchant` tinyint NOT NULL DEFAULT 0 COMMENT '是否允许商户端自助使用:0-否,1-是', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用, 1-启用', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`code`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付插件注册表'; + +CREATE TABLE IF NOT EXISTS `ma_payment_plugin_conf` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '所属商户ID,0表示平台配置', + `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码', + `config` json DEFAULT NULL COMMENT '插件初始化配置', + `settlement_cycle_type` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '结算周期类型:0-D0,1-D1,2-D7,3-T1,4-OTHER', + `settlement_cutoff_time` varchar(8) NOT NULL DEFAULT '23:59:59' COMMENT '结算截止时间', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_plugin_code` (`plugin_code`), + KEY `idx_merchant_plugin` (`merchant_id`, `plugin_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付插件API配置表'; + +CREATE TABLE IF NOT EXISTS `ma_payment_channel` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '所属商户ID,0表示平台通道', + `name` varchar(100) NOT NULL DEFAULT '' COMMENT '通道名称', + `split_rate_bp` int unsigned NOT NULL DEFAULT 10000 COMMENT '分成比例(bp)', + `cost_rate_bp` int unsigned NOT NULL DEFAULT 0 COMMENT '通道成本(bp)', + `channel_mode` tinyint NOT NULL DEFAULT 0 COMMENT '通道模式:0-代收,1-自收', + `pay_type_id` int unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID', + `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付插件编码', + `api_config_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT 'API配置ID,关联插件配置表主键ID', + `daily_limit_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '单日限额(分,0表示不限制)', + `daily_limit_count` int unsigned NOT NULL DEFAULT 0 COMMENT '单日限笔(0表示不限制)', + `min_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '单笔最小金额(分)', + `max_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '单笔最大金额(分)', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `sort_no` int NOT NULL DEFAULT 0 COMMENT '排序,越小优先级越高', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_name` (`name`), + KEY `idx_merchant_id_status` (`merchant_id`, `status`), + KEY `idx_pay_type_id_status` (`pay_type_id`, `status`), + KEY `idx_plugin_code_status` (`plugin_code`, `status`), + KEY `idx_channel_mode_status` (`channel_mode`, `status`), + KEY `idx_sort_no` (`sort_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付通道表'; + +CREATE TABLE IF NOT EXISTS `ma_payment_poll_group` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `group_name` varchar(100) NOT NULL DEFAULT '' COMMENT '轮询组名称', + `pay_type_id` int unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID', + `route_mode` tinyint NOT NULL DEFAULT 0 COMMENT '路由模式:0-顺序依次轮询,1-权重随机轮询,2-默认启用通道', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_group_name` (`group_name`), + KEY `idx_pay_type_status` (`pay_type_id`, `status`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付轮询组表'; + +CREATE TABLE IF NOT EXISTS `ma_payment_poll_group_channel` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `poll_group_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '轮询组ID', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID', + `sort_no` int NOT NULL DEFAULT 0 COMMENT '排序,越小优先级越高', + `weight` int NOT NULL DEFAULT 100 COMMENT '权重', + `is_default` tinyint NOT NULL DEFAULT 0 COMMENT '是否默认通道:0-否,1-是', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_poll_group_channel` (`poll_group_id`, `channel_id`), + KEY `idx_poll_group_status_sort` (`poll_group_id`, `status`, `sort_no`), + KEY `idx_channel_status` (`channel_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付轮询组-通道编排表'; + +CREATE TABLE IF NOT EXISTS `ma_payment_poll_group_bind` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID', + `pay_type_id` int unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID', + `poll_group_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '轮询组ID', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_group_pay_type` (`merchant_group_id`, `pay_type_id`), + KEY `idx_poll_group_status` (`poll_group_id`, `status`), + KEY `idx_group_status` (`merchant_group_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户分组-轮询组绑定表'; + +CREATE TABLE IF NOT EXISTS `ma_merchant_policy` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID(唯一)', + `settlement_cycle_override` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '结算周期覆盖:0-D0,1-D1,2-D7,3-T1,4-OTHER', + `auto_payout` tinyint NOT NULL DEFAULT 0 COMMENT '是否自动入账/自动处理:0-否,1-是', + `min_settlement_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '最小结算金额(分)', + `retry_policy_json` json DEFAULT NULL COMMENT '失败重试策略(JSON)', + `route_policy_json` json DEFAULT NULL COMMENT '路由策略(JSON)', + `fee_rule_override_json` json DEFAULT NULL COMMENT '费率/计费覆盖策略(JSON)', + `risk_policy_json` json DEFAULT NULL COMMENT '风控策略(JSON)', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_merchant_id` (`merchant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户策略预留表'; + +CREATE TABLE IF NOT EXISTS `ma_biz_order` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `biz_no` varchar(32) NOT NULL DEFAULT '' COMMENT '业务订单号', + `trace_no` varchar(32) NOT NULL DEFAULT '' COMMENT '统一追踪号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号(幂等)', + `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题', + `body` varchar(500) NOT NULL DEFAULT '' COMMENT '订单描述', + `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '异步通知地址', + `return_url` varchar(255) NOT NULL DEFAULT '' COMMENT '同步返回地址', + `client_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '客户端IP', + `device` varchar(32) NOT NULL DEFAULT '' COMMENT '设备类型', + `order_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '订单金额(分)', + `paid_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '已支付金额(分)', + `refund_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '已退款金额(分)', + `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '状态:0-待创建,1-支付中,2-成功,3-失败,4-关闭,5-超时', + `active_pay_no` varchar(32) DEFAULT NULL COMMENT '当前正在支付的支付单号', + `attempt_count` int unsigned NOT NULL DEFAULT 0 COMMENT '支付尝试次数', + `expire_at` datetime DEFAULT NULL COMMENT '过期时间', + `paid_at` datetime DEFAULT NULL COMMENT '支付成功时间', + `closed_at` datetime DEFAULT NULL COMMENT '关闭时间', + `failed_at` datetime DEFAULT NULL COMMENT '失败时间', + `timeout_at` datetime DEFAULT NULL COMMENT '超时时间', + `ext_json` json DEFAULT NULL COMMENT '商户扩展字段(JSON)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_merchant_order` (`merchant_id`, `merchant_order_no`), + UNIQUE KEY `uk_biz_no` (`biz_no`), + KEY `idx_trace_no` (`trace_no`), + KEY `idx_merchant_status_created` (`merchant_id`, `status`, `created_at`), + KEY `idx_active_pay_no` (`active_pay_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务订单表'; + +CREATE TABLE IF NOT EXISTS `ma_pay_order` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `pay_no` varchar(32) NOT NULL DEFAULT '' COMMENT '支付单号', + `biz_no` varchar(32) NOT NULL DEFAULT '' COMMENT '业务订单号', + `trace_no` varchar(32) NOT NULL DEFAULT '' COMMENT '统一追踪号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID快照', + `poll_group_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '轮询组ID快照', + `attempt_no` int unsigned NOT NULL DEFAULT 1 COMMENT '支付尝试序号', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID', + `pay_type_id` int unsigned NOT NULL DEFAULT 0 COMMENT '支付方式ID', + `plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '支付插件编码', + `channel_type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '通道类型:0-平台代收,1-商户自有', + `channel_mode` tinyint NOT NULL DEFAULT 0 COMMENT '通道模式:0-代收,1-自收', + `pay_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付金额(分)', + `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '异步通知地址', + `return_url` varchar(255) NOT NULL DEFAULT '' COMMENT '跳转通知地址', + `client_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '客户端 IP', + `device` varchar(32) NOT NULL DEFAULT '' COMMENT '设备类型', + `fee_rate_bp_snapshot` int unsigned NOT NULL DEFAULT 0 COMMENT '费率bp快照', + `split_rate_bp_snapshot` int unsigned NOT NULL DEFAULT 10000 COMMENT '分成比例bp快照', + `fee_estimated_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '预估手续费(分)', + `fee_actual_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '实际手续费(分)', + `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '状态:0-待创建,1-支付中,2-成功,3-失败,4-关闭,5-超时', + `fee_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '手续费状态:0-无,1-冻结,2-已扣,3-已释放', + `settlement_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '清算状态:0-无,1-待清算,2-已清算,3-已冲正', + `channel_request_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道请求号(幂等)', + `channel_order_no` varchar(64) DEFAULT NULL COMMENT '渠道订单号', + `channel_trade_no` varchar(64) DEFAULT NULL COMMENT '渠道交易号', + `channel_error_code` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道错误码', + `channel_error_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道错误信息', + `request_at` datetime DEFAULT NULL COMMENT '请求时间', + `paid_at` datetime DEFAULT NULL COMMENT '支付成功时间', + `expire_at` datetime DEFAULT NULL COMMENT '过期时间', + `closed_at` datetime DEFAULT NULL COMMENT '关闭时间', + `failed_at` datetime DEFAULT NULL COMMENT '失败时间', + `timeout_at` datetime DEFAULT NULL COMMENT '超时时间', + `callback_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '回调处理状态:0-未回调,1-成功,2-失败', + `callback_times` int unsigned NOT NULL DEFAULT 0 COMMENT '回调次数', + `ext_json` json DEFAULT NULL COMMENT '商户扩展字段(JSON)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pay_no` (`pay_no`), + UNIQUE KEY `uk_biz_attempt` (`biz_no`, `attempt_no`), + UNIQUE KEY `uk_channel_request_no` (`merchant_id`, `channel_request_no`), + KEY `idx_trace_no` (`trace_no`), + KEY `idx_merchant_status_created` (`merchant_id`, `status`, `created_at`), + KEY `idx_channel_trade_no` (`channel_trade_no`), + KEY `idx_channel_id_status` (`channel_id`, `status`), + KEY `idx_poll_group_id` (`poll_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付单表'; + +CREATE TABLE IF NOT EXISTS `ma_refund_order` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `refund_no` varchar(32) NOT NULL DEFAULT '' COMMENT '退款单号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID快照', + `biz_no` varchar(32) NOT NULL DEFAULT '' COMMENT '业务订单号', + `trace_no` varchar(32) NOT NULL DEFAULT '' COMMENT '统一追踪号', + `pay_no` varchar(32) NOT NULL DEFAULT '' COMMENT '支付单号', + `merchant_refund_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户退款号(幂等)', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID', + `refund_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '退款金额(分)', + `fee_reverse_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '手续费冲回金额(分)', + `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '状态:0-待创建,1-处理中,2-成功,3-失败,4-关闭', + `channel_request_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道退款请求号(幂等)', + `channel_refund_no` varchar(64) DEFAULT NULL COMMENT '渠道退款号', + `reason` varchar(255) NOT NULL DEFAULT '' COMMENT '退款原因', + `request_at` datetime DEFAULT NULL COMMENT '申请时间', + `processing_at` datetime DEFAULT NULL COMMENT '处理时间', + `succeeded_at` datetime DEFAULT NULL COMMENT '成功时间', + `failed_at` datetime DEFAULT NULL COMMENT '失败时间', + `retry_count` int unsigned NOT NULL DEFAULT 0 COMMENT '重试次数', + `last_error` varchar(255) NOT NULL DEFAULT '' COMMENT '最近错误信息', + `ext_json` json DEFAULT NULL COMMENT '商户扩展字段(JSON)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_refund_no` (`refund_no`), + UNIQUE KEY `uk_merchant_refund_no` (`merchant_id`, `merchant_refund_no`), + UNIQUE KEY `uk_channel_request_no` (`merchant_id`, `channel_request_no`), + KEY `idx_trace_no` (`trace_no`), + KEY `idx_biz_status_created` (`biz_no`, `status`, `created_at`), + KEY `idx_merchant_status_created` (`merchant_id`, `status`, `created_at`), + KEY `idx_pay_no` (`pay_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款单表'; + +CREATE TABLE IF NOT EXISTS `ma_transfer_order` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `biz_no` varchar(32) NOT NULL DEFAULT '' COMMENT '业务号', + `trace_no` varchar(32) NOT NULL DEFAULT '' COMMENT '统一追踪号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID快照', + `out_biz_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户转账单号', + `type` varchar(32) NOT NULL DEFAULT '' COMMENT '转账类型', + `account` varchar(100) NOT NULL DEFAULT '' COMMENT '收款账号', + `name` varchar(100) NOT NULL DEFAULT '' COMMENT '收款姓名', + `amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '转账金额(分)', + `cost_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '手续费(分)', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `bookid` varchar(64) NOT NULL DEFAULT '' COMMENT '书签ID', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '通道ID', + `channel_request_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道请求号', + `channel_order_no` varchar(64) DEFAULT NULL COMMENT '渠道订单号', + `channel_trade_no` varchar(64) DEFAULT NULL COMMENT '渠道交易号', + `channel_error_code` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道错误码', + `channel_error_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道错误信息', + `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '状态:0-待处理,1-成功,2-失败', + `request_at` datetime DEFAULT NULL COMMENT '请求时间', + `processing_at` datetime DEFAULT NULL COMMENT '处理中时间', + `succeeded_at` datetime DEFAULT NULL COMMENT '成功时间', + `failed_at` datetime DEFAULT NULL COMMENT '失败时间', + `ext_json` json DEFAULT NULL COMMENT '商户扩展字段(JSON)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_biz_no` (`biz_no`), + UNIQUE KEY `uk_merchant_channel_request_no` (`merchant_id`, `channel_request_no`), + UNIQUE KEY `uk_merchant_out_biz_no` (`merchant_id`, `out_biz_no`), + KEY `idx_trace_no` (`trace_no`), + KEY `idx_merchant_status_created` (`merchant_id`, `status`, `created_at`), + KEY `idx_channel_trade_no` (`channel_trade_no`), + KEY `idx_channel_id_status` (`channel_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='转账单表'; + +CREATE TABLE IF NOT EXISTS `ma_merchant_account` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID(唯一)', + `available_balance` bigint unsigned NOT NULL DEFAULT 0 COMMENT '可提现余额(分)', + `frozen_balance` bigint unsigned NOT NULL DEFAULT 0 COMMENT '冻结余额(分)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_merchant_id` (`merchant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户余额账户表'; + +CREATE TABLE IF NOT EXISTS `ma_merchant_account_ledger` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `ledger_no` varchar(32) NOT NULL DEFAULT '' COMMENT '流水号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `biz_type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '业务类型:0-支付冻结,1-支付扣费,2-支付释放,3-清算入账,4-退款冲正,5-人工调整', + `biz_no` varchar(32) NOT NULL DEFAULT '' COMMENT '业务单号', + `trace_no` varchar(32) NOT NULL DEFAULT '' COMMENT '统一追踪号', + `event_type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '事件类型:0-创建,1-成功,2-失败,3-冲正', + `direction` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '方向:0-入账,1-出账', + `amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '变动金额(分)', + `available_before` bigint unsigned NOT NULL DEFAULT 0 COMMENT '变动前可用余额(分)', + `available_after` bigint unsigned NOT NULL DEFAULT 0 COMMENT '变动后可用余额(分)', + `frozen_before` bigint unsigned NOT NULL DEFAULT 0 COMMENT '变动前冻结余额(分)', + `frozen_after` bigint unsigned NOT NULL DEFAULT 0 COMMENT '变动后冻结余额(分)', + `idempotency_key` varchar(100) NOT NULL DEFAULT '' COMMENT '幂等键', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `ext_json` json DEFAULT NULL COMMENT '账务扩展字段(JSON)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_idempotency_key` (`idempotency_key`), + KEY `idx_trace_no` (`trace_no`), + KEY `idx_merchant_biz` (`merchant_id`, `biz_type`, `biz_no`), + KEY `idx_merchant_created` (`merchant_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户余额流水表'; + +CREATE TABLE IF NOT EXISTS `ma_settlement_order` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `settle_no` varchar(32) NOT NULL DEFAULT '' COMMENT '清算单号', + `trace_no` varchar(32) NOT NULL DEFAULT '' COMMENT '统一追踪号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID快照', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID', + `cycle_type` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '周期类型:0-D0,1-D1,2-D7,3-T1,4-OTHER', + `cycle_key` varchar(32) NOT NULL DEFAULT '' COMMENT '周期标识,如日期或批次号', + `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '状态:0-待处理,1-处理中,2-成功,3-失败,4-已冲正', + `gross_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '清算前总额(分)', + `fee_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '手续费总额(分)', + `refund_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '退款本金(分)', + `fee_reverse_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '手续费冲回金额(分)', + `net_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '净额(分)', + `accounted_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '实际入账金额(分)', + `generated_at` datetime DEFAULT NULL COMMENT '生成时间', + `accounted_at` datetime DEFAULT NULL COMMENT '入账时间', + `completed_at` datetime DEFAULT NULL COMMENT '完成时间', + `failed_at` datetime DEFAULT NULL COMMENT '失败时间', + `fail_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '失败原因', + `ext_json` json DEFAULT NULL COMMENT '清算扩展字段(JSON)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cycle` (`merchant_id`, `channel_id`, `cycle_type`, `cycle_key`), + UNIQUE KEY `uk_settle_no` (`settle_no`), + KEY `idx_trace_no` (`trace_no`), + KEY `idx_merchant_status_created` (`merchant_id`, `status`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='清算单表'; + +CREATE TABLE IF NOT EXISTS `ma_settlement_item` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `settle_no` varchar(32) NOT NULL DEFAULT '' COMMENT '清算单号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID快照', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID', + `pay_no` varchar(32) NOT NULL DEFAULT '' COMMENT '支付单号', + `refund_no` varchar(32) DEFAULT NULL COMMENT '退款单号', + `pay_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付金额(分)', + `fee_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '手续费金额(分)', + `refund_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '退款本金(分)', + `fee_reverse_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '手续费冲回金额(分)', + `net_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '净额(分)', + `item_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '明细状态:0-待处理,1-成功,2-失败', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_settle_pay` (`settle_no`, `pay_no`), + KEY `idx_pay_no` (`pay_no`), + KEY `idx_channel_id` (`channel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='清算明细表'; + +CREATE TABLE IF NOT EXISTS `ma_channel_notify_log` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `notify_no` varchar(32) NOT NULL DEFAULT '' COMMENT '通知日志号', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '通道ID', + `notify_type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '通知类型:0-异步通知,1-查单', + `biz_no` varchar(32) NOT NULL DEFAULT '' COMMENT '业务单号', + `pay_no` varchar(32) DEFAULT NULL COMMENT '支付单号', + `channel_request_no` varchar(64) DEFAULT NULL COMMENT '渠道请求号', + `channel_trade_no` varchar(64) DEFAULT NULL COMMENT '渠道交易号', + `raw_payload` longtext COMMENT '原始报文', + `verify_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '验签状态:0-未知,1-成功,2-失败', + `process_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '处理状态:0-待处理,1-成功,2-失败', + `retry_count` int unsigned NOT NULL DEFAULT 0 COMMENT '重试次数', + `next_retry_at` datetime DEFAULT NULL COMMENT '下次重试时间', + `last_error` varchar(255) NOT NULL DEFAULT '' COMMENT '最近错误信息', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_notify_no` (`notify_no`), + UNIQUE KEY `uk_channel_notify` (`channel_id`, `notify_type`, `biz_no`), + KEY `idx_pay_status` (`pay_no`, `process_status`), + KEY `idx_trade_no` (`channel_trade_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通道通知日志表'; + +CREATE TABLE IF NOT EXISTS `ma_pay_callback_log` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `pay_no` varchar(32) NOT NULL DEFAULT '' COMMENT '支付单号', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '通道ID', + `callback_type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '回调类型:0-异步通知,1-同步返回', + `request_data` longtext COMMENT '请求原始数据(完整回调参数)', + `verify_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '验签状态:0-未知,1-成功,2-失败', + `process_status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '处理状态:0-待处理,1-成功,2-失败', + `process_result` longtext COMMENT '处理结果(JSON或文本)', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_pay_created` (`pay_no`, `created_at`), + KEY `idx_channel_created` (`channel_id`, `created_at`), + KEY `idx_callback_type` (`callback_type`), + KEY `idx_verify_status` (`verify_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调日志表'; + +CREATE TABLE IF NOT EXISTS `ma_channel_daily_stat` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID快照', + `channel_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付通道ID', + `stat_date` date NOT NULL COMMENT '统计日期', + `pay_success_count` int unsigned NOT NULL DEFAULT 0 COMMENT '支付成功笔数', + `pay_fail_count` int unsigned NOT NULL DEFAULT 0 COMMENT '支付失败笔数', + `pay_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '支付成功金额(分)', + `refund_count` int unsigned NOT NULL DEFAULT 0 COMMENT '退款笔数', + `refund_amount` bigint unsigned NOT NULL DEFAULT 0 COMMENT '退款金额(分)', + `avg_latency_ms` int unsigned NOT NULL DEFAULT 0 COMMENT '平均耗时(毫秒)', + `success_rate_bp` int unsigned NOT NULL DEFAULT 0 COMMENT '成功率(bp)', + `health_score` int NOT NULL DEFAULT 0 COMMENT '健康度评分', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_channel_stat_date` (`channel_id`, `stat_date`), + KEY `idx_merchant_date` (`merchant_id`, `stat_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通道日统计表'; + +CREATE TABLE IF NOT EXISTS `ma_notify_task` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `notify_no` varchar(32) NOT NULL DEFAULT '' COMMENT '通知任务号', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `merchant_group_id` int unsigned NOT NULL DEFAULT 0 COMMENT '商户分组ID快照', + `biz_no` varchar(32) NOT NULL DEFAULT '' COMMENT '业务单号', + `pay_no` varchar(32) NOT NULL DEFAULT '' COMMENT '支付单号', + `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '通知地址', + `notify_data` longtext COMMENT '通知内容', + `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '状态:0-待通知,1-成功,2-失败', + `retry_count` int unsigned NOT NULL DEFAULT 0 COMMENT '重试次数', + `next_retry_at` datetime DEFAULT NULL COMMENT '下次重试时间', + `last_notify_at` datetime DEFAULT NULL COMMENT '最后通知时间', + `last_response` longtext COMMENT '最后响应内容', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_notify_no` (`notify_no`), + UNIQUE KEY `uk_pay_no` (`pay_no`), + KEY `idx_status_retry` (`status`, `next_retry_at`), + KEY `idx_merchant_group` (`merchant_id`, `merchant_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户通知任务表'; + +CREATE TABLE IF NOT EXISTS `ma_system_config` ( + `config_key` varchar(100) NOT NULL COMMENT '配置项键名', + `config_value` longtext COMMENT '配置项值', + `group_code` varchar(50) NOT NULL DEFAULT '' COMMENT '配置分组', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`config_key`), + KEY `idx_group_code` (`group_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表'; + +CREATE TABLE IF NOT EXISTS `ma_file_asset` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '文件ID', + `scene` tinyint unsigned NOT NULL DEFAULT 4 COMMENT '文件场景:1-图片,2-证书,3-文本,4-其他', + `source_type` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '来源类型:1-上传,2-远程导入', + `visibility` tinyint unsigned NOT NULL DEFAULT 2 COMMENT '可见性:1-公开,2-私有', + `storage_engine` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '存储引擎:1-本地,2-阿里云OSS,3-腾讯云COS,4-远程引用', + `original_name` varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名', + `file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '存储文件名', + `file_ext` varchar(32) NOT NULL DEFAULT '' COMMENT '文件扩展名', + `mime_type` varchar(128) NOT NULL DEFAULT '' COMMENT '文件 MIME 类型', + `size` bigint unsigned NOT NULL DEFAULT 0 COMMENT '文件大小,单位字节', + `md5` char(32) NOT NULL DEFAULT '' COMMENT '文件 MD5', + `object_key` varchar(512) NOT NULL DEFAULT '' COMMENT '对象存储键或站点相对路径,如 storage/uploads/...', + `url` varchar(1024) NOT NULL DEFAULT '' COMMENT '默认访问地址', + `source_url` varchar(1024) NOT NULL DEFAULT '' COMMENT '远程来源地址', + `created_by` bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID', + `created_by_name` varchar(50) NOT NULL DEFAULT '' COMMENT '创建人名称', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_object_key` (`object_key`), + KEY `idx_scene` (`scene`), + KEY `idx_source_type` (`source_type`), + KEY `idx_visibility` (`visibility`), + KEY `idx_storage_engine` (`storage_engine`), + KEY `idx_created_by` (`created_by`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件表'; + +CREATE TABLE IF NOT EXISTS `ma_admin_user` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '管理员ID', + `username` varchar(32) NOT NULL DEFAULT '' COMMENT '登录用户名', + `password_hash` varchar(255) NOT NULL DEFAULT '' COMMENT '密码哈希', + `real_name` varchar(50) NOT NULL DEFAULT '' COMMENT '真实姓名', + `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号', + `email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱', + `is_super` tinyint NOT NULL DEFAULT 0 COMMENT '是否超级管理员', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `last_login_at` datetime DEFAULT NULL COMMENT '最后登录时间', + `last_login_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '最后登录IP', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员用户表'; + +CREATE TABLE IF NOT EXISTS `ma_merchant_api_credential` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `merchant_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商户ID', + `api_key` varchar(128) NOT NULL DEFAULT '' COMMENT '商户 API 凭证值(V1 MD5 签名使用)', + `merchant_public_key` text DEFAULT NULL COMMENT '商户 RSA 公钥(V2 验签使用)', + `sign_type` tinyint NOT NULL DEFAULT 0 COMMENT '签名类型:0-MD5(V1)', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `last_used_at` datetime DEFAULT NULL COMMENT '最后使用时间', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_at` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_merchant_id` (`merchant_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户API凭证表'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/doc/db/tables.md b/doc/db/tables.md new file mode 100644 index 0000000..7452dc5 --- /dev/null +++ b/doc/db/tables.md @@ -0,0 +1,73 @@ +# 数据表目录 + +这份目录页只负责“表去哪看、表大概干什么”,详细结构仍以 [`payment-middle-ddl.sql`](./payment-middle-ddl.sql) 为准。 + +## 基础字典 + +| 表名 | 用途 | 说明 | +| --- | --- | --- | +| `ma_payment_type` | 支付方式字典 | 维护支付方式编码、名称、图标和启用状态 | +| `ma_system_config` | 系统配置表 | 维护全局配置键值 | + +## 商户主体 + +| 表名 | 用途 | 说明 | +| --- | --- | --- | +| `ma_merchant_group` | 商户分组表 | 作为路由绑定和商户归类的输入条件 | +| `ma_merchant` | 商户表 | 商户主体资料,也是后台登录主体 | +| `ma_merchant_api_credential` | 商户 API 凭证表 | 开放接口签名凭证,V1 使用 MD5 key,V2 保存 RSA 公钥,与后台登录分离 | +| `ma_merchant_policy` | 商户策略预留表 | 预留的商户策略结构 | + +## 支付编排 + +| 表名 | 用途 | 说明 | +| --- | --- | --- | +| `ma_payment_plugin` | 支付插件注册表 | 扫描和注册支付插件定义 | +| `ma_payment_plugin_conf` | 支付插件 API 配置表 | 插件初始化配置和结算周期配置 | +| `ma_payment_channel` | 支付通道表 | 维护平台通道和商户自有通道 | +| `ma_payment_poll_group` | 支付轮询组表 | 承载轮询策略和候选通道编排 | +| `ma_payment_poll_group_channel` | 支付轮询组-通道编排表 | 轮询组内的通道顺序和权重配置 | +| `ma_payment_poll_group_bind` | 商户分组-轮询组绑定表 | 商户分组与轮询组的映射关系 | + +## 交易订单 + +| 表名 | 用途 | 说明 | +| --- | --- | --- | +| `ma_biz_order` | 业务订单表 | 统一业务订单入口,只承载业务事实、收银台恢复所需信息(subject/body/notify_url/return_url/client_ip/device)和业务扩展参数 | +| `ma_pay_order` | 支付单表 | 记录支付发起、状态推进和回调信息,扩展字段只留商户附加参数 | +| `ma_refund_order` | 退款单表 | 记录退款发起、状态推进和结果,扩展字段只留商户附加参数 | +| `ma_transfer_order` | 转账单表 | 记录转账发起、状态推进和渠道结果,扩展字段只留商户附加参数 | +| `ma_settlement_order` | 清算单表 | 记录清算批次和清算状态,扩展字段只留清算附加信息 | +| `ma_settlement_item` | 清算明细表 | 记录清算单内的明细拆分 | + +## 资金账户 + +| 表名 | 用途 | 说明 | +| --- | --- | --- | +| `ma_merchant_account` | 商户余额账户表 | 记录商户余额、冻结、可用等账户信息 | +| `ma_merchant_account_ledger` | 商户余额流水表 | 记录账户变更流水和账务明细 | + +## 运维日志 + +| 表名 | 用途 | 说明 | +| --- | --- | --- | +| `ma_channel_notify_log` | 通道通知日志表 | 记录通道侧通知、重试和失败原因 | +| `ma_pay_callback_log` | 支付回调日志表 | 记录支付回调处理和幂等结果 | +| `ma_channel_daily_stat` | 通道日统计表 | 记录通道成功率、耗时和健康度数据 | +| `ma_notify_task` | 商户通知任务表 | 记录商户异步通知任务和重试情况 | + +## 文件与后台 + +| 表名 | 用途 | 说明 | +| --- | --- | --- | +| `ma_file_asset` | 文件表 | 记录上传文件、预览、下载和存储位置 | +| `ma_admin_user` | 管理员用户表 | 记录后台管理员账号信息 | + +## 使用建议 + +- 先看 `tables.md`,再看 DDL +- 如果字段定义变了,以 DDL 为准 +- 如果表用途变了,先补 DDL 注释,再补这里的目录说明 +- `ext_json` 使用分区结构保存轻量运行上下文:顶层可放 `_protocol_version`,商户透传放 `merchant`,支付载体放 `payment`,收银台承接放 `presentation` +- 回调、通知、重试和原始报文使用专门日志/任务表,不进入订单扩展字段 +- 表级说明后面可以按业务域继续拆成独立文档 diff --git a/doc/deployment/README.md b/doc/deployment/README.md new file mode 100644 index 0000000..711415b --- /dev/null +++ b/doc/deployment/README.md @@ -0,0 +1,26 @@ +# 部署总说明 + +四个项目独立启动、构建和发布。后端负责 API 与页面兜底路由,前端负责生成静态产物。 + +## 命令速览 + +| 项目 | 开发/启动 | 构建 | +| --- | --- | --- | +| `mpay` | `php webman start` 或 `php windows.php` | 无前端构建 | +| `admin` | `pnpm dev` | `pnpm build:prod` | +| `mer` | `pnpm dev` | `pnpm build:prod` | +| `cashier` | `pnpm dev` | `pnpm build` | + +## 默认端口与路径 + +- 后端默认监听 `http://127.0.0.1:8787`。 +- 管理后台页面入口是 `/admin`,接口是 `/adminapi`。 +- 商户后台页面入口是 `/mer`,接口是 `/merapi`。 +- 收银台页面入口是 `/cashier`、`/payment`,接口是 `/api/cashier` 和 `/api/pay`。 + +## 部署建议 + +- 后端生产环境使用守护进程或进程管理工具托管。 +- `admin`、`mer`、`cashier` 的 `dist/` 可独立托管,也可发布到 `mpay/public` 下对应目录。 +- 如果前后端同域部署,确保 `/adminapi`、`/merapi`、`/api`、ePay V1 兼容入口都能转发到 `mpay`。 +- 环境变量说明见 [env.md](./env.md)。 diff --git a/doc/deployment/admin.md b/doc/deployment/admin.md new file mode 100644 index 0000000..c750b27 --- /dev/null +++ b/doc/deployment/admin.md @@ -0,0 +1,18 @@ +# 管理后台部署 + +命令默认在 `admin/` 目录执行。 + +```bash +pnpm install +pnpm build:prod +``` + +产物在 `dist/`。 + +## 路径 + +- 开发公共路径:`/admin` +- 生产默认公共路径:`/` +- 生产接口基址:`VITE_APP_BASE_URL=/`,运行时请求 `/adminapi` + +部署到子路径时,同步调整 `VITE_PUBLIC_PATH` 和网关重写规则。 diff --git a/doc/deployment/backend.md b/doc/deployment/backend.md new file mode 100644 index 0000000..1936bf5 --- /dev/null +++ b/doc/deployment/backend.md @@ -0,0 +1,31 @@ +# 后端部署 + +命令默认在 `mpay/` 目录执行。 + +## 前置条件 + +- PHP 8.1+ +- Composer +- MySQL +- Redis + +## 启动 + +```bash +composer install +Copy-Item .env.example .env +php webman start +``` + +Windows 开发环境需要同时启动自定义进程时: + +```bash +php windows.php +``` + +## 生产要点 + +- 配置数据库、Redis、JWT 密钥和支付运行时心跳。 +- 使用进程管理工具托管 `webman` 和 `payment-runtime`。 +- 执行 `php webman system:config-sync` 同步系统配置默认值。 +- 如使用 OSS/COS,先在系统配置中补齐存储参数。 diff --git a/doc/deployment/cashier.md b/doc/deployment/cashier.md new file mode 100644 index 0000000..a4cfb84 --- /dev/null +++ b/doc/deployment/cashier.md @@ -0,0 +1,19 @@ +# 收银台部署 + +命令默认在 `cashier/` 目录执行。 + +```bash +pnpm install +pnpm build +``` + +产物在 `dist/`。 + +## 路径 + +- 页面入口:`/cashier`、`/payment` +- 收银台 JSON API:`/api/cashier` +- ePay V2 / 开放支付 API:`/api/pay`、`/api/merchant`、`/api/transfer` +- ePay V1 兼容入口:`/submit.php`、`/mapi.php`、`/api.php` + +如果前端不走同域代理,配置 `VITE_API_BASE_URL` 指向后端;否则保持为空,通过相对路径访问 `/api`。 diff --git a/doc/deployment/env.md b/doc/deployment/env.md new file mode 100644 index 0000000..aaf0f04 --- /dev/null +++ b/doc/deployment/env.md @@ -0,0 +1,65 @@ +# 环境变量 + +本文只列当前代码读取或模板中声明的关键变量,不记录敏感值。 + +## 后端 `mpay` + +环境文件: + +- `.env` +- `.env.example` + +关键变量: + +| 变量 | 用途 | +| --- | --- | +| `DB_HOST`、`DB_PORT`、`DB_DATABASE`、`DB_USERNAME`、`DB_PASSWORD` | MySQL 连接 | +| `REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_DATABASE` | Redis 连接 | +| `CACHE_DRIVER` | 缓存驱动 | +| `AUTH_JWT_ISSUER`、`AUTH_JWT_LEEWAY` | JWT 通用参数 | +| `AUTH_ADMIN_JWT_SECRET`、`AUTH_ADMIN_JWT_TTL`、`AUTH_ADMIN_JWT_REDIS_PREFIX` | 管理后台登录 token | +| `AUTH_MERCHANT_JWT_SECRET`、`AUTH_MERCHANT_JWT_TTL`、`AUTH_MERCHANT_JWT_REDIS_PREFIX` | 商户后台登录 token | +| `PAY_RUNTIME_HEARTBEAT_SECONDS` | 支付运行时进程心跳间隔 | + +系统配置、站点 URL、支付运行时开关和存储参数主要由 `config/system_config.php` 定义,并可同步到数据库。 + +## 管理后台 `admin` + +环境文件: + +- `.env` +- `.env.development` +- `.env.production` +- `.env.test` + +关键变量: + +| 变量 | 用途 | +| --- | --- | +| `VITE_USER_NODE_ENV` | 环境标识 | +| `VITE_ROUTER_MODE` | 路由模式,开发默认为 `history`,生产默认为 `hash` | +| `VITE_PUBLIC_PATH` | 构建公共路径,开发默认为 `/admin` | +| `VITE_APP_BASE_URL` | 后端基址,前端会拼接 `/adminapi` | +| `VITE_APP_OPEN_MOCK` | 是否使用本地 mock,代码中有读取 | + +## 商户后台 `mer` + +环境文件和变量与 `admin` 基本一致;前端会在 `VITE_APP_BASE_URL` 后拼接 `/merapi`。 + +## 收银台 `cashier` + +环境文件: + +- `.env` +- `.env.example` + +关键变量: + +| 变量 | 用途 | +| --- | --- | +| `VITE_USE_MOCK` | 是否启用 mock 演示 | +| `VITE_API_PREFIX` | API 路径前缀,默认 `api` | +| `VITE_API_PROXY_TARGET` | Vite 开发代理目标,默认 `http://127.0.0.1:8787` | +| `VITE_API_BASE_URL` | 直连后端 API 基址,留空则走相对路径/开发代理 | + +注意:`cashier/.env.example` 中仍有 `VITE_ROUTE_PREFIX`,但当前 `cashier/src/config/index.ts` 未读取它;页面路由前缀实际固定为 `/cashier` 和 `/payment`。 diff --git a/doc/deployment/mer.md b/doc/deployment/mer.md new file mode 100644 index 0000000..9eea909 --- /dev/null +++ b/doc/deployment/mer.md @@ -0,0 +1,18 @@ +# 商户后台部署 + +命令默认在 `mer/` 目录执行。 + +```bash +pnpm install +pnpm build:prod +``` + +产物在 `dist/`。 + +## 路径 + +- 开发公共路径:`/mer` +- 生产默认公共路径:`/` +- 生产接口基址:`VITE_APP_BASE_URL=/`,运行时请求 `/merapi` + +部署到子路径时,同步调整 `VITE_PUBLIC_PATH` 和网关重写规则。 diff --git a/doc/faq.md b/doc/faq.md new file mode 100644 index 0000000..b5cf7b7 --- /dev/null +++ b/doc/faq.md @@ -0,0 +1,25 @@ +# 常见问题 + +## 根目录为什么不是 Git 仓库? + +根目录只是工作区容器。`admin`、`mer`、`cashier`、`mpay` 各自是独立 Git 仓库,建议打开 `MPAY_V2.code-workspace` 查看。 + +## 最新文档看哪里? + +看 `docs/`。`mpay/doc/` 是旧资料归档,不作为最新事实源。 + +## 文档和代码冲突怎么办? + +以当前代码、路由、前端 API 封装、环境模板和 DDL 为准,然后修正文档。 + +## 收银台入口到底是什么? + +页面入口是 `/cashier` 和 `/payment`;收银台 JSON API 是 `/api/cashier/*`;开放支付 API 是 `/api/pay/*`。 + +## 后端默认端口是什么? + +`config/process.php` 中 HTTP 服务默认监听 `0.0.0.0:8787`。 + +## 新文档写到哪里? + +跨项目说明写到 `docs/`;单个项目的启动、构建、联调说明写到对应项目的 `README.md`。 diff --git a/doc/frontend/README.md b/doc/frontend/README.md new file mode 100644 index 0000000..64af581 --- /dev/null +++ b/doc/frontend/README.md @@ -0,0 +1,32 @@ +# 前端总说明 + +当前有三套独立前端,均可单独安装、启动、构建和发布。 + +| 项目 | 技术栈 | 页面入口 | API 前缀 | 主要职责 | +| --- | --- | --- | --- | --- | +| `admin` | Vue 3 + Vite + Arco Design | `/admin` | `/adminapi` | 平台运营与配置 | +| `mer` | Vue 3 + Vite + Arco Design | `/mer` | `/merapi` | 商户自助后台 | +| `cashier` | Vue 3 + Vite + Tailwind CSS | `/cashier`、`/payment` | `/api/cashier` | 用户侧收银台 | + +## 共性 + +- 每个前端都有自己的 `package.json` 和环境文件。 +- `admin`、`mer` 的 axios 实例会在 `VITE_APP_BASE_URL` 后拼接 `/adminapi` 或 `/merapi`。 +- `cashier` 的 axios 实例使用 `VITE_API_BASE_URL` 作为基址,并在请求路径中拼接 `VITE_API_PREFIX`,默认是 `/api`。 +- 本地开发默认代理或直连 `http://127.0.0.1:8787`。 + +## 命令 + +| 项目 | 开发 | 构建 | 预览 | +| --- | --- | --- | --- | +| `admin` | `pnpm dev` | `pnpm build:dev` / `pnpm build:prod` / `pnpm build:test` | `pnpm preview` | +| `mer` | `pnpm dev` | `pnpm build:dev` / `pnpm build:prod` / `pnpm build:test` | `pnpm preview` | +| `cashier` | `pnpm dev` | `pnpm build` | `pnpm preview` | + +## 阅读入口 + +- [管理后台前端](./admin.md) +- [商户后台前端](./mer.md) +- [收银台前端](./cashier.md) +- [接口总说明](../api/README.md) +- [部署总说明](../deployment/README.md) diff --git a/doc/frontend/admin.md b/doc/frontend/admin.md new file mode 100644 index 0000000..6ff1a0c --- /dev/null +++ b/doc/frontend/admin.md @@ -0,0 +1,54 @@ +# 管理后台前端 + +`admin` 是平台管理后台,服务于运营、配置、财务和运维人员。 + +## 基本信息 + +- 目录:`admin/` +- 技术栈:Vue 3、Vite、TypeScript、Arco Design、Pinia、axios +- 页面入口:`/admin` +- API 前缀:`VITE_APP_BASE_URL + /adminapi` +- 开发默认后端:`http://127.0.0.1:8787` +- 开发公共路径:`/admin` +- 生产公共路径:`/` + +## 主要模块 + +- 商户、商户分组、商户 API 凭证、商户策略 +- 支付方式、支付插件、插件配置、支付通道、轮询组与绑定 +- 路由解析预览 +- 支付订单、退款订单、清算订单 +- 商户账户、资金流水 +- 通道日统计、通道通知日志、支付回调日志、商户通知任务 +- 文件资产、系统菜单、字典、系统配置页面、管理员用户 + +## 关键目录 + +```text +src/api/modules/ 接口封装 +src/router/ 静态路由和动态路由处理 +src/views/ 页面 +src/store/ Pinia 状态 +src/components/ 业务与通用组件 +``` + +## 文件上传 + +管理后台的文件资产接口是 `/adminapi/file-asset`。系统配置和插件配置中的上传字段仍使用 `type: "upload"`;需要走项目定制的图片/文件选择器时,在 `props.fileUpload` 中声明: + +- `selectorType`:`image` 或 `file` +- `scene`:图片、证书、文本或其他场景 +- `isLocal`:是否强制本地存储 +- `isPublic`:是否公开访问 +- `getKey`:上传成功后回填的响应字段,常用 `url`、`object_key`、`preview_url`、`id` + +完整文件资产行为见 [文件资产](../backend/files.md)。 + +## 常用命令 + +```bash +pnpm install +pnpm dev +pnpm build:prod +pnpm preview +``` diff --git a/doc/frontend/cashier.md b/doc/frontend/cashier.md new file mode 100644 index 0000000..653a15c --- /dev/null +++ b/doc/frontend/cashier.md @@ -0,0 +1,47 @@ +# 收银台前端 + +`cashier` 是用户侧支付前端,负责收银台上下文展示、确认支付、跳转和支付结果页。 + +## 基本信息 + +- 目录:`cashier/` +- 技术栈:Vue 3、Vite、TypeScript、Tailwind CSS、axios、qrcode +- 页面入口:`/cashier`、`/payment` +- JSON API:默认 `/api/cashier/*` +- 开放支付 API:后端同时提供 `/api/pay/*` +- 本地代理:`/api` 默认代理到 `http://127.0.0.1:8787` + +## 页面路由 + +| 路由 | 页面 | +| --- | --- | +| `/cashier` | 首页 | +| `/cashier/:bizNo` | 收银台入口页 | +| `/payment/:payNo` | 支付页 | +| `/payment/:payNo/redirect` | 支付中转页 | +| `/payment/:payNo/result` | 结果页 | +| `/payment/:payNo/success` | 成功结果页 | +| `/payment/:payNo/return` | 支付返回页 | +| `/payment/:payNo/error` | 支付错误页 | +| `/payment/:payNo/back` | 返回中转页 | + +页面路由前缀在 `src/config/index.ts` 中固定为 `CASHIER_PATH_PREFIX=/cashier`、`PAYMENT_PATH_PREFIX=/payment`。当前 `.env` 中的 `VITE_ROUTE_PREFIX` 只是遗留示例变量,代码没有读取它。 + +## 接口调用 + +`src/api/cashier.ts` 当前封装三类接口: + +- `GET /api/cashier/context?biz_no=...` +- `POST /api/cashier/confirm` +- `GET /api/cashier/pay-order?pay_no=...` + +`VITE_API_PREFIX` 会影响请求路径前缀,默认是 `api`;`VITE_API_BASE_URL` 用于直连后端,不填时本地通过 Vite 代理访问 `/api`。 + +## 常用命令 + +```bash +pnpm install +pnpm dev +pnpm build +pnpm preview +``` diff --git a/doc/frontend/menu.md b/doc/frontend/menu.md new file mode 100644 index 0000000..5b86719 --- /dev/null +++ b/doc/frontend/menu.md @@ -0,0 +1,146 @@ +# 菜单说明 + +> 真实菜单以 [`../../mpay/config/menu.php`](../../mpay/config/menu.php) 为准;稳定口径见 [`../standards.md`](../standards.md)。 + +这份文档只说明当前页面和菜单职责,不重复项目事实。 + +## 1. 管理后台当前菜单 + +```text +管理后台 +├─ 首页 +├─ 商户管理 +│ ├─ 商户列表 +│ └─ 商户分组 +├─ 通道中心 +│ ├─ 支付方式 +│ ├─ 支付插件 +│ ├─ 插件配置 +│ ├─ 通道列表 +│ └─ 通道日统计 +├─ 路由中心 +│ ├─ 轮询组 +│ ├─ 路由编排 +│ └─ 路由预览 +├─ 交易中心 +│ ├─ 支付订单 +│ ├─ 退款订单 +│ ├─ 清算订单 +│ ├─ 通道通知日志 +│ └─ 支付回调日志 +├─ 资金中心 +│ ├─ 资金账户 +│ └─ 资金流水 +├─ 系统管理 +│ ├─ 管理员用户 +│ └─ 系统配置 +└─ 文件中心 + └─ 文件管理 +``` + +## 2. 商户后台当前菜单 + +```text +商户后台 +├─ 首页 +├─ 通道中心 +│ ├─ 我的通道 +│ ├─ 路由预览 +│ └─ 商户 API 凭证 +├─ 交易中心 +│ ├─ 支付订单 +│ ├─ 退款订单 +│ └─ 清算记录 +├─ 资金中心 +│ ├─ 可提现余额 +│ └─ 余额流水 +└─ 基础设置 + ├─ 商户资料 + └─ 登录认证 +``` + +## 3. 页面职责 + +### 3.1 商户管理 + +| 页面 | 当前职责 | +|---|---| +| 商户列表 | 商户主体资料维护 + 通过操作台抽屉查看接入、商户 API 凭证、资金等摘要 | +| 商户分组 | 商户分组主数据维护,是路由绑定的输入条件 | + +说明: + +- 不再单独暴露“商户用户”页面 +- 商户更多能力通过商户列表内的操作台处理 + +### 3.2 通道中心 + +| 页面 | 当前职责 | +|---|---| +| 支付方式 | 支付方式字典维护 | +| 支付插件 | 扫描插件类并同步插件定义 | +| 插件配置 | 维护插件初始化配置 | +| 通道列表 | 维护平台通道和商户自有通道 | +| 通道日统计 | 查看通道健康度、成功率、耗时等 | + +### 3.3 路由中心 + +| 页面 | 当前职责 | +|---|---| +| 轮询组 | 维护轮询组本身,并在组内编排候选通道 | +| 路由编排 | 维护“商户分组 + 支付方式 -> 轮询组”的绑定关系 | +| 路由预览 | 按真实链路预览某次支付会命中哪个通道 | + +当前路由模式: + +- 顺序依次轮询 +- 权重随机轮询 +- 默认启用通道 + +### 3.4 交易中心 + +| 页面 | 当前职责 | +|---|---| +| 支付订单 | 平台支付单查询、详情、回调和状态跟踪 | +| 退款订单 | 退款单查询、退款推进和退款结果跟踪 | +| 清算订单 | 平台代收清算批次、明细和结算结果查看 | +| 通道通知日志 | 查看通道侧通知记录和失败原因 | +| 支付回调日志 | 查看支付回调记录和幂等处理结果 | + +### 3.5 资金中心 + +| 页面 | 当前职责 | +|---|---| +| 资金账户 | 商户账户余额、冻结余额、可用余额查看 | +| 资金流水 | 商户账户入账、出账、冻结、解冻流水查看 | + +### 3.6 系统管理 + +| 页面 | 当前职责 | +|---|---| +| 管理员用户 | 平台管理员账号维护 | +| 系统配置 | 平台级配置维护与同步 | + +### 3.7 文件中心 + +| 页面 | 当前职责 | +|---|---| +| 文件管理 | 统一管理图片、证书、文本和其他附件,支持本地存储和对象存储切换 | + +### 3.8 商户后台 + +| 页面 | 当前职责 | +|---|---| +| 我的通道 | 只读查看当前商户可见通道 | +| 路由预览 | 输入支付方式和金额,查看当前商户会命中的通道 | +| 商户 API 凭证 | 查看当前商户的商户 API 凭证相关信息 | +| 商户资料 | 维护联系人与结算信息 | +| 登录认证 | 修改登录密码,查看登录相关信息 | + +## 4. 口径差异 + +- 轮询组已从“通道中心”移动到“路由中心” +- 商户管理不再拆“商户用户” +- 商户更多能力仍收在列表页内的操作台 +- 商户后台登录已改为“商户号 + 密码” +- 商户 API 凭证与商户后台登录彻底分离 diff --git a/doc/frontend/mer.md b/doc/frontend/mer.md new file mode 100644 index 0000000..249cfbc --- /dev/null +++ b/doc/frontend/mer.md @@ -0,0 +1,46 @@ +# 商户后台前端 + +`mer` 是商户自助后台,服务于商户查看自身资料、凭证、订单、退款和资金信息。 + +## 基本信息 + +- 目录:`mer/` +- 技术栈:Vue 3、Vite、TypeScript、Arco Design、Pinia、axios +- 页面入口:`/mer` +- API 前缀:`VITE_APP_BASE_URL + /merapi` +- 开发默认后端:`http://127.0.0.1:8787` +- 开发公共路径:`/mer` +- 生产公共路径:`/` + +## 主要模块 + +- 商户登录、退出、当前用户资料 +- 商户资料维护、修改登录密码 +- 我的通道维护、商户插件配置、路由预览 +- 商户 API 凭证查看与重置 +- 支付订单、退款订单、退款重试 +- 清算记录、可提现余额、资金流水 +- 菜单树和字典项 + +## 自助通道配置 + +商户端通道中心包含“我的通道”和“插件配置”。“插件配置”只展示当前商户自己的配置;“我的通道”新增或编辑时只能绑定当前商户的插件配置,并且插件来源受管理后台“商户端自助使用”开关控制。 + +## 关键目录 + +```text +src/api/modules/ 接口封装 +src/router/ 路由 +src/views/ 页面 +src/store/ 状态 +src/components/ 组件 +``` + +## 常用命令 + +```bash +pnpm install +pnpm dev +pnpm build:prod +pnpm preview +``` diff --git a/doc/overview.md b/doc/overview.md new file mode 100644 index 0000000..d7eaff8 --- /dev/null +++ b/doc/overview.md @@ -0,0 +1,62 @@ +# 项目总览 + +`MPAY_V2` 是一个支付中台工作区,后端基于 Webman,前端拆成管理后台、商户后台和收银台三套独立应用。 + +## 工作区组成 + +| 目录 | 类型 | 职责 | +| --- | --- | --- | +| `mpay` | PHP / Webman 后端 | 支付、退款、路由、插件、回调、商户、资金、清算、文件与系统配置 | +| `admin` | Vue 3 管理后台 | 商户、通道、插件、轮询、订单、退款、清算、资金、日志、文件、系统配置 | +| `mer` | Vue 3 商户后台 | 商户资料、API 凭证、可用通道、路由预览、订单、退款、清算、余额和流水 | +| `cashier` | Vue 3 收银台 | 收银台上下文、确认支付、支付跳转、支付单状态和结果页 | +| `docs` | 文档中心 | 当前项目事实、接口、部署和协作说明 | + +根目录只是工作区容器;`admin`、`mer`、`cashier`、`mpay` 各自保留独立 Git 仓库。 + +## 核心链路 + +```text +商户系统/ePay 请求 + -> 后端校验商户与签名 + -> 创建业务单/支付单 + -> 商户分组路由解析 + -> 轮询组选择支付通道 + -> 支付插件调用第三方 + -> 收银台展示或跳转 + -> 回调/查单推进状态 + -> 通知商户 + -> 清算后写入商户资金与流水 +``` + +## 当前入口 + +| 场景 | 页面入口 | API 入口 | 后端路由文件 | +| --- | --- | --- | --- | +| 管理后台 | `/admin` | `/adminapi` | `mpay/app/route/admin.php` | +| 商户后台 | `/mer` | `/merapi` | `mpay/app/route/mer.php` | +| 收银台 | `/cashier`、`/payment` | `/api/cashier`、`/api/pay` | `mpay/app/route/api.php` | +| ePay V1 兼容 | `/submit.php`、`/mapi.php`、`/api.php` | 同左 | `mpay/app/route/api.php` | +| ePay V2 / 开放 API | 无固定页面 | `/api/pay`、`/api/merchant`、`/api/transfer` | `mpay/app/route/api.php` | + +## 后端重点模块 + +- `app/http`:管理后台、商户后台、开放 API 的控制器、中间件和参数校验。 +- `app/route`:显式路由;默认路由已关闭。 +- `app/service/payment`:支付、退款、清算、路由、插件、通知、追踪和 ePay 协议。 +- `app/service/merchant`:商户主体、登录、分组、策略、商户后台能力和 API 凭证。 +- `app/service/account`:商户资金账户和流水。 +- `app/service/file`:文件资产、上传、预览、下载和存储驱动。 +- `app/common/payment`:支付插件实现,当前包含 Alipay、ePay V1、ePay V2 和模板插件。 + +## 数据范围 + +当前 DDL 包含支付配置、商户、订单、退款、转账、资金、清算、日志、通知、文件、系统配置和管理员用户表。完整表结构以 [当前 DDL](./db/payment-middle-ddl.sql) 为准。 + +## 推荐阅读 + +1. [架构与请求流](./architecture.md) +2. [后端总说明](./backend/README.md) +3. [前端总说明](./frontend/README.md) +4. [接口总说明](./api/README.md) +5. [部署总说明](./deployment/README.md) diff --git a/doc/plan.md b/doc/plan.md new file mode 100644 index 0000000..2aa0862 --- /dev/null +++ b/doc/plan.md @@ -0,0 +1,47 @@ +# 当前阶段计划 + +当前重点是联调稳定、自动化测试、通知补偿、运维监控和安全收边。稳定事实见 [稳定口径](./standards.md)。 + +## 已具备的主干能力 + +- 管理后台、商户后台、收银台三端独立运行。 +- 支付、退款、清算、资金流水、通道路由和插件运行时已经形成主链路。 +- ePay V1 兼容入口与 ePay V2 开放 API 已在后端路由中显式维护。 +- 文件资产支持本地、远程 URL、OSS、COS。 +- `payment-runtime` 已承担通知重试、支付超时扫描和支付中订单主动查单。 +- 文档中心已收口到 `docs/`。 + +## P0 联调闭环 + +- [ ] 用真实第三方配置跑通支付创建、收银台展示、回调、查单和商户通知。 +- [ ] 跑通退款创建、退款查询和退款状态推进。 +- [ ] 跑通平台代收清算入账链路。 +- [ ] 验证商户自有通道余额扣费、冻结和释放逻辑。 +- [ ] 验证路由命中与轮询策略符合预期。 + +## P1 测试补齐 + +- [ ] 补支付、退款、清算、资金、路由、插件装配的自动化测试。 +- [ ] 补回调幂等、重复通知、通知重试测试。 +- [ ] 将关键检查沉淀到 `app/command` 烟雾测试命令。 + +## P2 运维与补偿 + +- [ ] 为通知重试超限、回调失败、路由无命中、通道不可用增加告警。 +- [ ] 支持商户通知人工补发。 +- [ ] 统一通知、回调、通道统计日志字段。 +- [ ] 梳理数据库备份、恢复和第三方通道异常应急流程。 + +## P3 安全与配置 + +- [ ] 检查 `.env.example` 是否覆盖必填配置。 +- [ ] 清理默认密码、默认商户凭证、默认数据库账号等生产风险。 +- [ ] 增加后台登录限流和开放接口限流。 +- [ ] 补齐敏感数据脱敏和关键操作审计。 + +## P4 文档与接入 + +- [ ] 补开放支付 API 联调说明。 +- [ ] 补回调协议、签名规则和金额口径说明。 +- [ ] 完善支付插件开发约定。 +- [ ] 持续清理文档、注释、页面中的旧口径。 diff --git a/doc/standards.md b/doc/standards.md new file mode 100644 index 0000000..a6f0046 --- /dev/null +++ b/doc/standards.md @@ -0,0 +1,73 @@ +# 稳定口径 + +本文只记录当前项目需要长期保持一致的事实和约束。更细的字段、表结构和接口参数以代码、路由文件和 DDL 为准。 + +## 项目边界 + +- `mpay` 是 Webman 后端,提供管理后台、商户后台、收银台、ePay 兼容和开放 API。 +- `admin`、`mer`、`cashier` 是三套独立 Vue 前端,分别构建和发布。 +- 根目录不是业务仓库,只作为多项目工作区。 +- 新文档统一维护在 `docs/`;`mpay/doc/` 只作为旧资料归档。 + +## 返回格式 + +- 成功态统一使用 `HTTP 200`。 +- 业务状态由响应体 `code` 判断,前端拦截器按 `code` 处理成功、失败和登录过期。 +- 控制器负责参数接收和响应包装;服务层和仓库层遇到业务错误直接抛项目异常。 +- 金额统一使用“分”作为存储和接口计算单位。 +- 模型时间序列化统一输出 `Y-m-d H:i:s`。 + +## 鉴权与签名 + +- 管理后台走 `/adminapi`,登录后通过 `Authorization` 头传管理员 token。 +- 商户后台走 `/merapi`,登录主体是 `ma_merchant`,登录字段是 `merchant_no + password`。 +- 商户开放 API 凭证在 `ma_merchant_api_credential`,只用于接口签名,不参与商户后台登录。 +- ePay V1/V2 兼容接口的签名规则独立维护,不能反向污染后台登录逻辑。 + +## 支付路由 + +支付选路固定围绕以下数据关系展开: + +```text +商户 -> 商户分组 -> 路由绑定 -> 轮询组 -> 轮询组通道编排 -> 支付通道 -> 支付插件配置 +``` + +路由模式当前为: + +- `0`:顺序轮询 +- `1`:权重随机 +- `2`:默认启用通道 + +`pay_types`、`transfer_types` 存支付方式代码数组;支付插件类由运行时根据插件配置解析。 + +## 资金与清算 + +- 商户资金账户表是 `ma_merchant_account`。 +- 资金流水表是 `ma_merchant_account_ledger`。 +- 平台代收成功后不直接进入商户可提现余额,清算成功后才入账。 +- 支付回调、通道通知日志、商户通知任务、通道日统计和清算记录都属于核心链路。 + +## 服务层命名 + +- `*Service`:对控制器或外部调用方暴露的门面。 +- `*QueryService`:查询、列表、详情和展示拼装。 +- `*CommandService`:新增、修改、删除、状态变更。 +- `*LifecycleService`:支付、退款、清算等状态推进。 +- `*CallbackService`:第三方回调和通知处理。 +- `*SyncService`:配置同步、插件扫描、目录同步。 +- `*SupportService`:有业务语义的共享辅助能力;不要只转发基础工具方法。 + +## 文件资产 + +- 文件资产模型名是 `FileRecord`,数据表是 `ma_file_asset`。 +- 管理后台文件接口前缀是 `/adminapi/file-asset`。 +- 支持本地存储、远程 URL 导入、阿里云 OSS、腾讯云 COS。 +- 上传字段仍使用 `type: "upload"`;需要走项目定制选择器时,在 `props.fileUpload` 中声明场景、可见性、存储偏好和回填字段。 +- `object_key` 是站点相对路径或对象键;`url` 只保存公开可访问地址;私有文件可通过预览/下载接口临时读取。 + +## 运行与配置 + +- 后端 HTTP 进程默认监听 `0.0.0.0:8787`。 +- `payment-runtime` 进程负责商户通知重试、支付超时扫描和支付中订单主动查单。 +- 站点 URL、支付运行时开关、存储配置等长期配置优先进入系统配置,不在业务代码中临时拼接。 +- 新增系统配置后执行 `php webman system:config-sync` 同步默认值。