CRMEB/backend/app/services/pay/PayServices.php
吴昊天 51f76ec771 fix(资金流水): 只记录真实资金进出,余额结算与退回余额不再记账
- PayServices 新增 isCashChannel():仅第三方在线渠道 + 线下支付为真;
  余额支付、后台代付、空支付方式(0 元单)一律为假。isRealPaymentChannel()
  保留余额支付语义(站内有结算无资金流),内部委托 isCashChannel 判定现金渠道。
- CapitalFlowServices::setFlow() 作为唯一出口统一拦截非现金渠道与未知业务类型,
  避免各业务分支各自判断导致漏过滤;nickname/phone 缺省兼容,返回是否写入。
- 退款链路:refund_type=balance(含原路退款改退余额)属于站内结转,
  RefundExecutorServices::writeFlow() 与 RefundEffectServices 均跳过记账;
  其余退款同样走 isCashChannel 判定,余额支付订单退款不再产生流水。
- 补充 .crmeb 售后文档的资金流水口径说明。
2026-10-23 14:17:15 +08:00

913 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\services\pay;
use crmeb\exceptions\ApiException;
use crmeb\services\CacheService;
use crmeb\services\driver\DriverRegistry;
use crmeb\services\pay\PayChannelConfig;
/**
* 支付统一入口
* 所有支付方式映射、配置均从 driver/pay/ 各子目录的 plugin.json 自动读取
* 支付代码中不做任何平台判断(isH5/isApp 等)
* Class PayServices
* @package app\services\pay
*/
class PayServices
{
public const REFUND_SUCCESS = 'SUCCESS';
public const REFUND_PROCESSING = 'PROCESSING';
public const REFUND_FAILED = 'FAILED';
public const REFUND_UNKNOWN = 'UNKNOWN';
//支付方式 channel_key 常量(供业务层引用,非硬编码逻辑)
const WEIXIN_PAY = 'weixin';
const YUE_PAY = 'yue';
const OFFLINE_PAY = 'offline';
const ALIAPY_PAY = 'alipay';
const ALLIN_PAY = 'allinpay';
const FRIEND = 'friend';
const BANK = 'bank';
/**
* 获取支付方式名称映射
* 从 eb_pay_channel 表动态读取,管理员新增/修改支付方式后自动生效
* @return array channel_key => channel_name
*/
public static function getPayType(): array
{
try {
/** @var \app\model\driver\pay\PayChannel $model */
$model = app()->make(\app\model\driver\pay\PayChannel::class);
$list = $model->where('is_del', 0)
->where('status', 1)
->order('sort', 'desc')
->column('channel_name', 'channel_key');
return $list ?: [];
} catch (\Exception $e) {
return [];
}
}
protected const CACHE_KEY = 'pay_channel_info';
protected const CACHE_TTL = 3600;
/**
* @var array
*/
protected $options = [];
/**
* @param string $key
* @param $value
* @return $this
*/
public function setOption(string $key, $value)
{
$this->options[$key] = $value;
return $this;
}
/**
* @param array $value
* @return $this
*/
public function setOptions(array $value)
{
$this->options = $value;
return $this;
}
/**
* @param string $key
* @param null $default
* @return mixed|null
*/
protected function getOption(string $key, $default = null)
{
return $this->options[$key] ?? $default;
}
/**
* 发起支付
* @param string $channelKey 支付方式标识(channel_key)
* @param string $orderId
* @param string $price
* @param string $successAction
* @param string $body
* @param array $options 调用方传入的额外参数(如 openid, platform)
* @return array|string
*/
public function pay(string $channelKey, string $orderId, string $price, string $successAction, string $body, array $options = [])
{
try {
$driverName = $this->resolveDriverName($channelKey);
// 从 plugin.json 的 default_options 自动读取配置并合并
$defaultOpts = $this->buildDefaultOptions($channelKey);
$options = $options + $defaultOpts;
// 从 eb_pay_channel_platform 读取当前端的支付类型,传入驱动
// 驱动不再自行判断平台,由数据库配置决定
$platform = $options['platform'] ?? \app\services\PlatformDetector::detect();
$options['pay_type'] = $this->resolvePayType($channelKey, $platform);
$options['platform'] = $platform;
/** @var \crmeb\services\pay\Pay $pay */
$pay = app()->make('\\crmeb\\services\\pay\\Pay', [$driverName]);
if (in_array($channelKey, ['weixin', 'weixin_v3'], true)) {
$options['payment_context'] = $pay->getPaymentContext($options);
$options['payment_context']['payer_uid'] = (int)($options['payer_uid'] ?? 0);
$records = app()->make(PayRecordServices::class);
$previous = $records->find($orderId);
$source = $records->source(strtolower($successAction), $orderId);
$ttl = strtolower($successAction) === 'product' ? (int)ceil((float)sys_config('order_cancel_time', 0) * 3600) : 7200;
$expires = (int)($previous['context']['expires_at'] ?? ((int)$source['add_time'] + ($ttl > 0 ? $ttl : 7200)));
if ($expires <= time() + 60) throw new ApiException('订单支付时间已过期,请重新下单');
$options['payment_context']['expires_at'] = $expires;
$options['time_expire'] = date('YmdHis', $expires);
$options['expire_time'] = $expires;
app()->make(PayRecordServices::class)->prepare($orderId, strtolower($successAction), $price, $options['payment_context']);
if (strtolower($successAction) === 'product') {
$this->saveV3PaymentContext($orderId, $options['payment_context']);
}
}
try {
$result = $pay->create($orderId, $price, $successAction, $body, '', $options);
if (isset($options['payment_context']) && is_array($result)) {
if ($options['pay_type'] === 'JSAPI') $result['paymentMode'] = $options['payment_context']['mode'];
$urlKey = isset($result['mweb_url']) ? 'mweb_url' : (isset($result['h5_url']) ? 'h5_url' : '');
$returnUrl = (string)($options['quitUrl'] ?? '');
if ($urlKey !== '' && $returnUrl !== '') {
$target = parse_url($returnUrl);
$site = parse_url((string)sys_config('site_url'));
if ($target && $site && in_array($target['scheme'] ?? '', ['https', 'http'], true)
&& ($target['host'] ?? '') === ($site['host'] ?? '') && ($target['port'] ?? null) === ($site['port'] ?? null)) {
$result[$urlKey] .= (str_contains($result[$urlKey], '?') ? '&' : '?')
. http_build_query(['redirect_url' => $returnUrl], '', '&', PHP_QUERY_RFC3986);
}
}
}
return $result;
} finally {
if (isset($options['payment_context'])) app()->make(PayRecordServices::class)->finishCreation($orderId);
}
} catch (\Exception $e) {
if (strpos($e->getMessage(), 'api unauthorized rid') !== false) {
throw new ApiException('请在微信支付配置中将小程序商户号选择改为商户号绑定');
}
throw new ApiException($e->getMessage());
}
}
/**
* 保存 V3 原支付信息,保留订单扩展数据且不保存密钥。
* @param string $orderNo 订单号
* @param array $context 支付信息
* @return void
*/
protected function saveV3PaymentContext(string $orderNo, array $context): void
{
/** @var \app\dao\order\OrderCenterDao $dao */
$dao = app()->make(\app\dao\order\OrderCenterDao::class);
$order = $dao->get(['order_no' => $orderNo], ['id', 'pay_status', 'extra_info']);
if (!$order || (int)$order['pay_status'] !== 0) {
throw new ApiException('订单不存在或已支付');
}
$extra = $order['extra_info'] ?? [];
if (is_string($extra)) {
$extra = json_decode($extra, true) ?: [];
}
// 同一订单不能留下两种商户模式的有效支付参数,否则退款无法确定归属。
$previous = $extra['payment'] ?? [];
foreach (['channel_key', 'platform', 'mode', 'mch_id', 'sub_mch_id', 'app_id', 'openid'] as $key) {
if (!empty($previous[$key]) && $previous[$key] !== ($context[$key] ?? '')) {
throw new ApiException('该订单已发起其他商户或小程序模式的支付,请使用原支付配置');
}
}
if ($previous === $context) {
return;
}
$extra['payment'] = $context;
if (!$dao->updateUnpaidPaymentContext((int)$order['id'], $order['extra_info'] ?? null,
json_encode($extra, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))) {
throw new \RuntimeException('订单支付信息已变化,请重新发起支付');
}
}
/**
* 从原订单提取 V3 退款信息,旧普通 V3 订单仍使用商户订单号。
* @param array $orderInfo 原订单
* @return array
*/
protected function getV3PaymentContext(array $orderInfo): array
{
$extra = $orderInfo['extra_info'] ?? [];
if (is_string($extra)) {
$extra = json_decode($extra, true) ?: [];
}
$context = $extra['payment'] ?? [];
return $context;
}
/**
* 根据 channel_key 解析 Pay 管理器的驱动标识
* 支持 version_switch 动态切换驱动版本(如微信 v2/v3)
* @param string $channelKey
* @return string
*/
public function resolveDriverName(string $channelKey): string
{
$channels = $this->getChannelInfo();
if (!isset($channels[$channelKey])) {
throw new ApiException('不支持的支付方式: ' . $channelKey);
}
$channel = $channels[$channelKey];
// 检查是否有版本切换配置
$versionSwitch = $channel['version_switch'] ?? null;
if ($versionSwitch && isset($versionSwitch['config_key'], $versionSwitch['compare_value'], $versionSwitch['target_driver'])) {
if ((string)sys_config($versionSwitch['config_key']) === (string)$versionSwitch['compare_value']) {
// 检查目标驱动是否存在
if (isset($channels['_drivers'][$versionSwitch['target_driver']])) {
return $versionSwitch['target_driver'];
}
}
}
return $channel['driver_key'];
}
/**
* 解析当前端的支付类型
* 优先从 eb_pay_channel_platform 读取(管理员可覆盖),不存在则从 plugin.json 的 pay_type_map 读取默认值
* @param string $channelKey 支付方式标识
* @param string $platform 端标识
* @return string 支付类型(JSAPI/APP/NATIVE/h5 等),无记录返回空字符串
*/
public function resolvePayType(string $channelKey, string $platform): string
{
// 优先从数据库读取(管理员可覆盖)
try {
$record = \app\model\driver\pay\PayChannelPlatform::where('channel_key', $channelKey)
->where('platform', $platform)
->where('status', 1)
->find();
if ($record) {
return (string)$record['pay_type'];
}
} catch (\Exception $e) {
// 表不存在时忽略
}
// 从 plugin.json 的 pay_type_map 读取默认值
$channelInfo = $this->getChannelInfoByKey($channelKey);
return $channelInfo['pay_type_map'][$platform] ?? '';
}
/**
* 获取支付返回类型标识(从 plugin.json 动态读取,不做平台判断)
* @param string $channelKey
* @param string $platform 当前平台标识
* @return string
*/
public function getPayStatusType(string $channelKey, string $platform = ''): string
{
$info = $this->getChannelInfoByKey($channelKey);
$statusType = $info['pay_status_type'] ?? ($channelKey . '_pay');
// 字符串:直接返回
if (is_string($statusType)) {
return $statusType;
}
// 数组:按平台动态返回
if (is_array($statusType)) {
if ($platform && isset($statusType[$platform])) {
return $statusType[$platform];
}
return $statusType['default'] ?? ($channelKey . '_pay');
}
return $channelKey . '_pay';
}
/**
* 获取指定支付方式的扩展信息
* @param string $channelKey
* @return array
*/
public function getChannelInfoByKey(string $channelKey): array
{
$channels = $this->getChannelInfo();
return $channels[$channelKey] ?? [];
}
/**
* 判断支付方式是否为第三方在线支付渠道(微信/支付宝/通联等)
* 渠道集合由驱动注册中心扫描 driver/pay 插件动态获得,新增支付驱动即自动纳入,调用方无需硬编码渠道枚举
* @param string $payType 支付方式标识
* @return bool
*/
public function isThirdPartyChannel(string $payType): bool
{
return $this->getChannelInfoByKey($payType) !== [];
}
/**
* 判断支付方式是否为真实资金支付渠道(第三方在线 + 余额 + 线下)
* 余额/线下为系统固有渠道(非驱动插件,用常量判定);排除 0 元单等无资金流的支付方式
* @param string $payType 支付方式标识
* @return bool
*/
public function isRealPaymentChannel(string $payType): bool
{
// 余额支付:站内虚拟资产结算,属于"有结算但无资金流",在此方法中视为真实支付
if ($payType === self::YUE_PAY) {
return true;
}
return $this->isCashChannel($payType);
}
/**
* 判断支付方式是否产生真实资金进出(现金收付渠道)
* 包含:第三方在线支付渠道(由驱动注册中心动态获取)+ 线下支付
* 排除:余额支付(站内虚拟资产,充值后消费不再产生现金流动)、
* 空支付方式(0 元单)、后台代付等无资金流动的结算方式
* 资金流水(eb_capital_flow)只允许记录本方法判定为 true 的支付方式
* @param string $payType 支付方式标识
* @return bool
*/
public function isCashChannel(string $payType): bool
{
$payType = trim($payType);
if ($payType === '' || $payType === self::YUE_PAY) {
return false;
}
// 线下支付为系统固有渠道,不随驱动扩展,常量判定合法
if ($payType === self::OFFLINE_PAY) {
return true;
}
// 第三方渠道:从驱动注册中心动态获取
return $this->isThirdPartyChannel($payType);
}
/**
* 根据驱动标识获取驱动信息
* @param string $driverKey
* @return array
*/
public function getDriverInfoByKey(string $driverKey): array
{
$channels = $this->getChannelInfo();
return $channels['_drivers'][$driverKey] ?? [];
}
/**
* 根据 channel_key 获取驱动标识(用于退款/转账等场景)
* 直接从 plugin.json 的 driver_key 读取,不做版本切换
* @param string $channelKey
* @return string
*/
public function getDriverByChannelKey(string $channelKey): string
{
$info = $this->getChannelInfoByKey($channelKey);
return $info['driver_key'] ?? $channelKey;
}
/**
* 统一退款接口
* 根据 channel_key 动态路由到对应支付驱动进行退款
* @param string $channelKey 支付方式标识(channel_key)
* @param string $orderId 订单号
* @param array $refundData 退款数据(refund_price, pay_price, trade_no 等)
* @param array $orderInfo 订单信息(用于特殊场景判断)
* @return mixed
*/
public function refund(string $channelKey, string $orderId, array $refundData, array $orderInfo = [])
{
try {
$driverName = $this->resolveDriverName($channelKey);
/** @var \crmeb\services\pay\Pay $pay */
$pay = app()->make('\\crmeb\\services\\pay\\Pay', [$driverName]);
$no = $orderId;
if (!empty($refundData['trade_no'])) {
$no = $refundData['trade_no'];
$refundData['type'] = 'trade_no';
}
if (in_array($channelKey, ['weixin', 'weixin_v3'], true)) {
$context = app()->make(PayRecordServices::class)->find($orderId)['context'] ?? $this->getV3PaymentContext($orderInfo);
if ($context && ($context['channel_key'] ?? '') !== $channelKey) throw new ApiException('原支付渠道与退款渠道不一致');
$refundData['payment_context'] = $context;
$refundData['order_id'] = $orderId;
if (($context['mode'] ?? '') === 'mini_shop') {
$no = $orderId;
$refundData['pay_new_weixin_open'] = true;
$refundData['open_id'] = $context['openid'] ?? '';
$refundData['trade_no'] = $context['transaction_id'] ?? '';
$refundData['refund_no'] = $refundData['refund_id'] ?? '';
} elseif (!empty($context['transaction_id'])) {
$no = $context['transaction_id'];
$refundData['type'] = 'trade_no';
}
}
if ($channelKey === 'weixin' && empty($refundData['payment_context'])) {
if (!empty($orderInfo['is_channel']) && $orderInfo['is_channel'] == 1) {
if (!empty($orderInfo['trade_no'])) {
$refundData['trade_no'] = $orderInfo['trade_no'];
$refundData['type'] = 'trade_no';
$no = $orderInfo['trade_no'];
}
// 按实际支付渠道读取配置,V2 与 V3 不共享小程序商户号模式。
$refundData['pay_new_weixin_open'] = PayChannelConfig::get($channelKey)['pay_new_weixin_open'] ?? '';
} else {
$refundData['wechat'] = true;
}
}
if ($channelKey === 'alipay') {
$refundData['totalAmount'] = $refundData['pay_price'] ?? '';
}
if ($channelKey === 'allinpay') {
$refundData['order_id'] = $orderId;
// 通联驱动的第一个参数会作为 reqsn,必须使用系统固定退款号。
$no = (string)($refundData['refund_id'] ?? $orderId);
}
return $pay->refund($no, $refundData);
} catch (\Exception $e) {
throw new ApiException($e->getMessage());
}
}
/**
* 使用固定退款号提交退款,并将不同驱动的返回值标准化。
* 驱动抛出的异常一律先视为 UNKNOWN;调用方必须查询原退款号,不能换号重提。
*/
public function submitRefund(
string $channelKey,
string $orderId,
string $refundNo,
string $amount,
string $payAmount,
array $orderInfo = []
): array {
try {
$raw = $this->refund($channelKey, $orderId, [
'refund_price' => $amount,
'pay_price' => $payAmount,
'refund_id' => $refundNo,
'trade_no' => $orderInfo['trade_no'] ?? '',
], $orderInfo);
return $this->normalizeRefundResult($raw, true);
} catch (\Throwable $e) {
return [
'status' => self::REFUND_UNKNOWN,
'channel_refund_id' => '',
'response' => [],
'message' => $e->getMessage(),
];
}
}
/**
* 查询固定退款号的渠道状态。
*/
public function queryRefundStatus(string $channelKey, string $orderId, string $refundNo, array $other = []): array
{
try {
$driverName = $this->resolveDriverName($channelKey);
if (in_array($channelKey, ['weixin', 'weixin_v3'], true)) {
$other['payment_context'] = app()->make(PayRecordServices::class)->find($orderId)['context']
?? $this->getV3PaymentContext($other['order_info'] ?? []);
$other['order_id'] = $orderId;
unset($other['order_info']);
}
/** @var \crmeb\services\pay\Pay $pay */
$pay = app()->make('\\crmeb\\services\\pay\\Pay', [$driverName]);
if ($channelKey === self::ALIAPY_PAY) {
$raw = $pay->queryRefund($orderId, $refundNo, $other);
} else {
$raw = $pay->queryRefund($refundNo, $refundNo, ['type' => 'out_refund_no'] + $other);
}
if ($raw === null) {
return [
'status' => self::REFUND_UNKNOWN,
'channel_refund_id' => '',
'response' => [],
'message' => '当前支付驱动未返回可判定的退款查询结果',
];
}
return $this->normalizeRefundResult($raw, false);
} catch (\Throwable $e) {
return [
'status' => self::REFUND_UNKNOWN,
'channel_refund_id' => '',
'response' => [],
'message' => $e->getMessage(),
];
}
}
/**
* @param mixed $raw
*/
protected function normalizeRefundResult($raw, bool $submit): array
{
if (is_object($raw)) {
$raw = json_decode(json_encode($raw, JSON_UNESCAPED_UNICODE), true) ?: [];
}
$response = $this->sanitizeRefundResponse(is_array($raw) ? $raw : []);
$candidate = strtoupper((string)(
$response['status']
?? $response['refund_status']
?? $response['refundStatus']
?? $response['refund_status_0']
?? $response['trade_status']
?? ''
));
if ($candidate === '' && (string)($response['code'] ?? '') === '10000') {
if ($submit && ($response['fundChange'] ?? $response['fund_change'] ?? '') === 'Y') $candidate = 'SUCCESS';
if (!$submit && bccomp((string)($response['refundAmount'] ?? $response['refund_amount'] ?? '0'), '0', 2) > 0) $candidate = 'SUCCESS';
}
if ($candidate === '' && isset($response['trxstatus'])) {
$candidate = ['0000' => 'SUCCESS', '2000' => 'PROCESSING', '2008' => 'PROCESSING'][(string)$response['trxstatus']] ?? 'FAILED';
}
if ($submit && $candidate === '' && isset($response['result_code'])) {
$candidate = strtoupper((string)$response['result_code']) === 'SUCCESS' ? 'PROCESSING' : 'FAILED';
}
if (in_array($candidate, ['SUCCESS', 'REFUND_SUCCESS', 'TRADE_SUCCESS'], true)) {
$status = self::REFUND_SUCCESS;
} elseif (in_array($candidate, ['PROCESSING', 'PENDING', 'CHANGE', 'NOTSURE'], true)) {
$status = self::REFUND_PROCESSING;
} elseif (in_array($candidate, ['CLOSED', 'ABNORMAL', 'FAILED', 'FAIL'], true)) {
$status = self::REFUND_FAILED;
} elseif ($submit) {
// 没有明确渠道结果不能视为到账。
$status = self::REFUND_UNKNOWN;
} else {
$status = self::REFUND_UNKNOWN;
}
return [
'status' => $status,
'channel_refund_id' => (string)($response['refund_id'] ?? $response['trade_no'] ?? ''),
'response' => $response,
'message' => (string)($response['message'] ?? $response['msg'] ?? ''),
];
}
protected function sanitizeRefundResponse(array $response): array
{
$sanitized = [];
foreach ($response as $key => $value) {
if (preg_match('/secret|password|private|token|certificate|api[_-]?key/i', (string)$key)) {
$sanitized[$key] = '***';
} elseif (is_array($value)) {
$sanitized[$key] = $this->sanitizeRefundResponse($value);
} elseif (is_scalar($value) || $value === null) {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* 动态检查支付方式是否可用
* 根据 channel_key 从数据库获取支付渠道状态
* @param string $payType 支付方式标识
* @return bool
*/
public function checkPayType(string $payType): bool
{
/** @var \app\services\driver\pay\PayChannelServices $payChannelServices */
$payChannelServices = app()->make(\app\services\driver\pay\PayChannelServices::class);
$channelInfo = $this->getChannelInfoByKey($payType);
if (empty($channelInfo)) {
return false;
}
$channelKey = $channelInfo['channel_key'] ?? $payType;
$res = $payChannelServices->getChannelStatus($channelKey);
return $res;
}
/**
* 获取指定 platform 下当前启用的驱动标识
* 用于提现/转账/回调等场景,需要知道当前使用的是哪个版本
* @param string $platform 所属平台(wechat/alipay/allinpay)
* @return string 驱动标识,无匹配返回空字符串
*/
public function getActiveDriverByPlatform(string $platform): string
{
try {
$record = \app\model\driver\pay\PayChannel::where('platform', $platform)
->where('status', 1)
->order('sort', 'desc')
->find();
if ($record) {
$info = $this->getChannelInfoByKey($record['channel_key']);
return $info['driver_key'] ?? '';
}
} catch (\Exception $e) {
// 表不存在时忽略
}
return '';
}
/**
* 检查指定 platform 下当前启用的驱动是否具备某项能力
* @param string $platform 所属平台(wechat/alipay/allinpay)
* @param string $capability 能力标识(merchant_pay_new / query_transfer / transfer_notify 等)
* @return bool
*/
public function hasCapability(string $platform, string $capability): bool
{
$driverKey = $this->getActiveDriverByPlatform($platform);
if (!$driverKey) {
return false;
}
// 从 _drivers 中按 driver_key 找到对应的 info
$channels = $this->getChannelInfo();
$info = $channels['_drivers'][$driverKey] ?? [];
$capabilities = $info['capabilities'] ?? [];
return in_array($capability, $capabilities);
}
/**
* 获取支付方式的特殊 URL
* @param string $channelKey
* @param string $urlKey
* @return string
*/
public function getChannelUrl(string $channelKey, string $urlKey): string
{
$info = $this->getChannelInfoByKey($channelKey);
return $info['pay_urls'][$urlKey] ?? '';
}
/**
* 获取所有支付方式名称映射
* @return array
*/
public function getPayTypeMap(): array
{
$channels = $this->getChannelInfo();
$map = [];
foreach ($channels as $key => $info) {
if ($key[0] === '_') continue;
$map[$key] = $info['name'] ?? $key;
}
return $map;
}
/**
* 从 plugin.json 的 default_options 自动读取配置,构建支付默认参数
* @param string $channelKey
* @return array
*/
protected function buildDefaultOptions(string $channelKey): array
{
$info = $this->getChannelInfoByKey($channelKey);
$defaultOpts = $info['default_options'] ?? [];
if (empty($defaultOpts)) {
return [];
}
$options = [];
$channelConfig = PayChannelConfig::get($channelKey);
foreach ($defaultOpts as $configKey) {
$value = array_key_exists($configKey, $channelConfig) ? $channelConfig[$configKey]
: ($configKey === 'pay_new_weixin_open' ? false : sys_config($configKey));
if ($value !== null) {
// 布尔类型转换
if ($value === '1' || $value === 'true') {
$options[$configKey] = true;
} elseif ($value === '0' || $value === 'false') {
$options[$configKey] = false;
} else {
$options[$configKey] = $value;
}
}
}
return $options;
}
/**
* 处理支付回调(控制器只传 type,所有逻辑在此处理)
* @param string $type 回调URL中的type参数(即channel_key)
* @return string|\think\Response
*/
public function handleNotify(string $type): string|\think\Response
{
// 1. 解析 channel_key(type 直接对应 channel_key)
$channelKey = $this->resolveChannelKeyByType($type);
// 2. 获取驱动标识(支持版本切换)
$driverKey = $this->resolveDriverName($channelKey);
// 3. 从支付渠道配置表获取所有配置值
$configValues = $this->getChannelConfigValues($channelKey);
// 4. 调用驱动的 handleNotify,传递原始 type 参数
/** @var \crmeb\services\pay\Pay $pay */
$pay = app()->make(\crmeb\services\pay\Pay::class, [$driverKey, $configValues]);
$response = $pay->handleNotify($type);
// 保留驱动的 HTTP 状态码与响应头,使验签/业务失败可被支付平台识别并重试。
if ($response instanceof \think\Response) {
return $response;
}
return (string) $response;
}
/**
* 根据回调type解析channel_key
* @param string $type
* @return string
*/
protected function resolveChannelKeyByType(string $type): string
{
// 直接匹配 channel_key
$channels = $this->getChannelInfo();
if (isset($channels[$type])) {
return $type;
}
// 通联支付回调(allin开头)-> allinpay
if (strpos($type, 'allin') === 0) {
return 'allinpay';
}
// 微信V3回调:驱动 notify_url 历史上使用 v3wechat,渠道 key 为 weixin_v3,
// 保持商户平台已配置的回调 URL 不变,在此做别名映射
if ($type === 'v3wechat') {
return 'weixin_v3';
}
throw new ApiException('不支持的回调类型: ' . $type);
}
/**
* 从驱动注册中心获取所有支付扩展信息(含缓存)
* @return array
*/
protected function getChannelInfo(): array
{
$cached = CacheService::get(self::CACHE_KEY, null);
if (is_array($cached) && $this->isValidChannelCache($cached)) {
return $cached;
}
$channels = $this->buildChannelInfo();
CacheService::set(self::CACHE_KEY, $channels, self::CACHE_TTL);
return $channels;
}
/**
* 校验支付渠道缓存是否包含可用的渠道映射。
* 旧版本曾缓存空数组,不能把该值当作有效结果长期复用。
* @param array $cached
* @return bool
*/
protected function isValidChannelCache(array $cached): bool
{
foreach ($cached as $key => $info) {
if (is_string($key) && str_starts_with($key, '_')) {
continue;
}
if (is_array($info) && !empty($info['channel_key']) && !empty($info['driver_key'])) {
return true;
}
}
return false;
}
/**
* 清除支付渠道信息缓存
* @return bool
*/
public function clearChannelInfoCache(): bool
{
return CacheService::delete(self::CACHE_KEY);
}
/**
* 构建支付渠道信息
* @return array
*/
protected function buildChannelInfo(): array
{
$channels = [];
// 历史安装数据通过 eb_pay_channel.driver 保存驱动目录名,作为清单字段缺失时的兜底关联。
$driverChannels = [];
try {
$driverChannels = \app\model\driver\pay\PayChannel::where('is_del', 0)
->column('driver', 'channel_key');
} catch (\Throwable $e) {
// 支付渠道表不可用时仍允许已安装驱动通过清单元数据注册。
}
// 统一走驱动注册中心,并为旧安装记录补齐支付驱动清单中的扩展字段。
foreach (DriverRegistry::scan('pay') as $driverKey => $info) {
$dirName = (string)($info['dir_name'] ?? '');
$metadata = PayChannelConfig::getDriverMetadata($dirName);
foreach ($metadata as $key => $value) {
if (!array_key_exists($key, $info) || $info[$key] === '' || $info[$key] === [] || $info[$key] === null) {
$info[$key] = $value;
}
}
$channelKey = (string)($info['channel_key'] ?? '');
if ($channelKey === '' && $dirName !== '') {
foreach ($driverChannels as $candidateKey => $candidateDriver) {
if ((string)$candidateDriver === $dirName) {
$channelKey = (string)$candidateKey;
break;
}
}
}
if ($channelKey === '') {
continue;
}
$info['channel_key'] = $channelKey;
$info['driver_key'] = (string)($info['driver_key'] ?? $driverKey);
// 同一 channel_key 可能有多个驱动(如 weixin 有 v2 和 v3)
if (!isset($channels[$channelKey])) {
$channels[$channelKey] = $info;
}
// 以 driver_key 为索引记录所有驱动(用于 version_switch)
$channels['_drivers'][$driverKey] = $info;
}
return $channels;
}
/**
* 从支付渠道配置表获取所有配置值(优先从 config_fields,兼容从 system_config)
* @param string $channelKey
* @return array
*/
protected function getChannelConfigValues(string $channelKey): array
{
try {
/** @var PayChannelServices $payChannelServices */
$payChannelServices = app()->make(PayChannelServices::class);
return $payChannelServices->getChannelConfigValues($channelKey);
} catch (\Exception $e) {
// 表不存在时忽略,返回空数组
return [];
}
}
/**
* 从支付渠道配置表获取单个配置值(优先从 config_fields,兼容从 system_config)
* @param string $channelKey
* @param string $configKey
* @return mixed
*/
protected function getConfigValue(string $channelKey, string $configKey)
{
$configValues = $this->getChannelConfigValues($channelKey);
if (isset($configValues[$configKey])) {
return $configValues[$configKey];
}
return sys_config($configKey);
}
}