mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-29 05:50:50 +00:00
519 lines
22 KiB
PHP
519 lines
22 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||
// +----------------------------------------------------------------------
|
||
// | Author: CRMEB Team <admin@crmeb.com>
|
||
// +----------------------------------------------------------------------
|
||
|
||
namespace driver\pay\V3WechatPay;
|
||
|
||
use crmeb\exceptions\PayException;
|
||
use crmeb\services\pay\BasePay;
|
||
use crmeb\services\pay\PayInterface;
|
||
use EasyWeChat\Pay\Application as PayApplication;
|
||
use think\facade\Db;
|
||
use think\facade\Event;
|
||
|
||
/**
|
||
* 微信支付v3
|
||
* Class Driver
|
||
* @package driver\pay\V3WechatPay
|
||
*/
|
||
class Driver extends BasePay implements PayInterface
|
||
{
|
||
|
||
/**
|
||
* 微信支付 V3 渠道标识,与本驱动 plugin.json 的 channel_key 保持一致。
|
||
* 支付回调保存此标识,退款才能使用原 V3 驱动和商户配置。
|
||
*/
|
||
const WEIXIN_PAY_CHANNEL_KEY = 'weixin_v3';
|
||
|
||
/**
|
||
* @var PayClient
|
||
*/
|
||
protected $instance;
|
||
|
||
/**
|
||
* 各渠道 appid
|
||
* @var array
|
||
*/
|
||
protected array $appids = [];
|
||
|
||
/**
|
||
* 本次调用使用的 V3 渠道配置。
|
||
* @var array
|
||
*/
|
||
protected array $payment = [];
|
||
|
||
/** @var string 客户端证书文件指纹 */
|
||
protected string $clientFingerprint = '';
|
||
|
||
/**
|
||
* @param array $config
|
||
* @return mixed|void
|
||
*/
|
||
protected function initialize(array $config = [])
|
||
{
|
||
// 支付插件配置(微信支付已插件化,统一存储于 eb_pay_channel.config_fields)
|
||
$payment = $this->payment = $this->channelConfig();
|
||
|
||
// V3 渠道独立维护各端 AppID,不读取 V2 weixin 渠道配置。
|
||
$wechatAppid = trim((string)($payment['wechat_appid'] ?? ''));
|
||
$miniAppid = trim((string)($payment['routine_appId'] ?? ''));
|
||
$appAppid = trim((string)($payment['wechat_app_appid'] ?? ''));
|
||
|
||
// H5 下单必须携带已与商户号绑定的 AppID;公众号未配置时可使用小程序或移动应用 AppID。
|
||
$h5Appid = $wechatAppid !== '' ? $wechatAppid : ($miniAppid !== '' ? $miniAppid : $appAppid);
|
||
$this->appids = [
|
||
'app' => $appAppid,
|
||
'wechat' => $wechatAppid,
|
||
'miniprog' => $miniAppid,
|
||
'web' => $wechatAppid,
|
||
'h5' => $h5Appid,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 按需初始化普通 V3 客户端;shop/pay 仅使用小程序 access_token。
|
||
* @param array $context 原支付信息
|
||
* @return PayClient
|
||
*/
|
||
protected function getPayClient(array $context = [])
|
||
{
|
||
$this->refreshPayment();
|
||
$payment = $this->payment;
|
||
if (!empty($context['mch_id']) && $context['mch_id'] !== trim((string)($payment['pay_weixin_mchid'] ?? ''))) {
|
||
throw new PayException('原支付商户号与当前V3配置不一致,请恢复原商户配置后退款');
|
||
}
|
||
if (isset($context['sub_mch_id']) && $context['sub_mch_id'] !==
|
||
(!empty($payment['mer_type']) ? trim($payment['pay_sub_merchant_id'] ?? '') : '')) {
|
||
throw new PayException('原支付子商户号与当前V3配置不一致');
|
||
}
|
||
// 站点域名属系统公用配置(站点信息),允许 driver 直接 sys_config 读取
|
||
$siteUrl = trim((string)sys_config('site_url'));
|
||
$privateKeyPath = $this->getPemPath('pay_weixin_client_key');
|
||
$this->assertPrivateKey($privateKeyPath);
|
||
$certificatePath = $this->getPemPath('pay_weixin_client_cert');
|
||
if ($certificatePath === '' || !is_file($certificatePath) || !is_readable($certificatePath)) {
|
||
throw new PayException('微信支付商户证书文件不存在,请重新上传 apiclient_cert.pem');
|
||
}
|
||
|
||
$publicKeyId = trim((string)($payment['v3_pay_public_key'] ?? ''));
|
||
$publicKeyPath = $publicKeyId !== '' ? $this->getPemPath('v3_pay_public_pem') : '';
|
||
$fingerprint = hash('sha256', serialize([$payment, $siteUrl, hash_file('sha256', $privateKeyPath),
|
||
hash_file('sha256', $certificatePath), is_file($publicKeyPath) ? hash_file('sha256', $publicKeyPath) : '']));
|
||
if ($this->instance !== null && $this->clientFingerprint === $fingerprint) return $this->instance;
|
||
|
||
$payConfig = [
|
||
'mch_id' => $payment['pay_weixin_mchid'] ?? '',
|
||
'secret_key' => $payment['pay_weixin_key_v3'] ?? '',
|
||
'private_key' => $privateKeyPath,
|
||
'certificate' => $certificatePath,
|
||
'notify_url' => $siteUrl . '/api/pay/notify/v3wechat',
|
||
'http' => [
|
||
'timeout' => 30.0,
|
||
'throw' => false,
|
||
],
|
||
];
|
||
|
||
// 服务商模式
|
||
if (!empty($payment['mer_type'])) {
|
||
$payConfig['sub_mch_id'] = trim($payment['pay_sub_merchant_id'] ?? '');
|
||
$payConfig['sp_appid'] = trim($payment['sp_appid'] ?? '');
|
||
}
|
||
|
||
// 平台公钥模式优先;证书模式自动下载平台证书(供回调验签与敏感字段加密)
|
||
if ($publicKeyId !== '') {
|
||
$payConfig['platform_certs'] = [$publicKeyId => $publicKeyPath];
|
||
$application = new PayApplication($payConfig);
|
||
} else {
|
||
$platformCerts = PayClient::downloadPlatformCerts(new PayApplication($payConfig));
|
||
if ($platformCerts) {
|
||
$payConfig['platform_certs'] = $platformCerts;
|
||
}
|
||
$application = new PayApplication($payConfig);
|
||
}
|
||
|
||
$this->instance = new PayClient($application, $this->appids, $publicKeyId);
|
||
$this->clientFingerprint = $fingerprint;
|
||
return $this->instance;
|
||
}
|
||
|
||
/**
|
||
* 长驻进程按最新渠道配置更新应用身份并失效旧客户端。
|
||
* @return void
|
||
*/
|
||
protected function refreshPayment(): void
|
||
{
|
||
if ($this->channelConfig() !== $this->payment) {
|
||
$this->instance = null;
|
||
$this->initialize();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取实际支付模式及非密钥配置,供订单保存并在退款时复用。
|
||
* @param array $options 支付参数
|
||
* @return array
|
||
*/
|
||
public function getPaymentContext(array $options): array
|
||
{
|
||
$this->refreshPayment();
|
||
$payment = $this->payment;
|
||
$platform = $options['platform'] ?? '';
|
||
$miniShop = $platform === 'wechat_mini' && ($options['pay_type'] ?? '') === 'JSAPI'
|
||
&& !empty($payment['pay_new_weixin_open']);
|
||
if ($miniShop && (empty($options['openid']) || empty($payment['routine_appId'])
|
||
|| empty($payment['routine_appsecret']) || empty($payment['pay_new_weixin_mchid']))) {
|
||
throw new PayException('新版小程序支付需要配置AppID、AppSecret、商户号并提供支付者OpenID');
|
||
}
|
||
return [
|
||
'channel_key' => self::WEIXIN_PAY_CHANNEL_KEY,
|
||
'platform' => $platform,
|
||
'mode' => $miniShop ? 'mini_shop' : 'v3',
|
||
'mch_id' => trim((string)($payment[$miniShop ? 'pay_new_weixin_mchid' : 'pay_weixin_mchid'] ?? '')),
|
||
'sub_mch_id' => !$miniShop && !empty($payment['mer_type']) ? trim($payment['pay_sub_merchant_id'] ?? '') : '',
|
||
'app_id' => (string)($this->appids[$platform === 'wechat_mini' ? 'miniprog'
|
||
: ($platform === 'app' ? 'app' : ($platform === 'h5' ? 'h5' : 'wechat'))] ?? ''),
|
||
'openid' => (string)($options['openid'] ?? ''),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 从 eb_pay_channel 读取本驱动支付插件配置
|
||
*
|
||
* 架构豁免说明:支付驱动插件化后配置统一存储于 eb_pay_channel.config_fields
|
||
* (app\services\driver\pay\PayChannelServices 负责读写),本类位于 driver 第三方
|
||
* 驱动底座,driver 层无 DAO 体系,故此处 Db 直查为合规做法(同本类 getPemPath 模式),
|
||
* 禁止"修复"为 app 层 DAO
|
||
* @return array<string,string>
|
||
*/
|
||
protected function channelConfig(): array
|
||
{
|
||
try {
|
||
$channel = Db::name('pay_channel')->where('channel_key', 'weixin_v3')->where('is_del', 0)->find();
|
||
} catch (\Throwable $e) {
|
||
return [];
|
||
}
|
||
$config = [];
|
||
if ($channel && !empty($channel['config_fields'])) {
|
||
// Db 直查返回 text 列原始 JSON 字符串,需解码(模型 cast 在 driver 层不生效)
|
||
$configFields = is_string($channel['config_fields']) ? json_decode($channel['config_fields'], true) : $channel['config_fields'];
|
||
if (is_array($configFields)) {
|
||
foreach ($configFields as $field) {
|
||
if (isset($field['field'])) {
|
||
$config[$field['field']] = $field['value'] ?? '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return $config;
|
||
}
|
||
|
||
/**
|
||
* 从 V3 支付渠道配置获取证书路径。
|
||
*
|
||
* V2 与 V3 使用相同的字段名,但证书路径属于各自 eb_pay_channel.config_fields,
|
||
* 不能回退到全局 system_pem,否则会把 V2 证书带入 V3 签名。
|
||
* @param string $name
|
||
* @return string
|
||
*/
|
||
public function getPemPath(string $name): string
|
||
{
|
||
// 证书路径只从 V3 渠道读取,不读取 V2 或应用配置。
|
||
$payment = $this->channelConfig();
|
||
$path = trim((string)($payment[$name] ?? ''));
|
||
// 未配置证书时直接返回空串,避免空值拼接出无效路径
|
||
if ($path === '') {
|
||
return '';
|
||
}
|
||
if (strstr($path, 'http://') || strstr($path, 'https://')) {
|
||
$path = parse_url($path)['path'] ?? '';
|
||
}
|
||
$path = str_replace('\\', '/', trim($path));
|
||
if ($path === '') {
|
||
return '';
|
||
}
|
||
// 仅检查项目根目录内的绝对路径,避免把 /xxx.pem 这类上传 URL 直接交给 open_basedir。
|
||
$rootPath = str_replace('\\', '/', rtrim(root_path(), '/\\')) . '/';
|
||
$normalizedPath = str_replace('\\', '/', $path);
|
||
if (str_starts_with($normalizedPath, $rootPath) && is_file($path) && is_readable($path)) {
|
||
return $path;
|
||
}
|
||
$relativePath = ltrim($path, '/');
|
||
if (str_starts_with($relativePath, 'uploads/')) {
|
||
$relativePath = substr($relativePath, strlen('uploads/'));
|
||
}
|
||
$runtimePath = root_path('runtime/pem') . $relativePath;
|
||
if (is_file($runtimePath) && is_readable($runtimePath)) {
|
||
return $runtimePath;
|
||
}
|
||
$publicPath = public_path('uploads') . $relativePath;
|
||
if (is_file($publicPath) && is_readable($publicPath)) {
|
||
return $publicPath;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 校验微信支付商户私钥,避免把空路径或普通文本传给 openssl_sign。
|
||
*
|
||
* @param string $path
|
||
* @return void
|
||
*/
|
||
protected function assertPrivateKey(string $path): void
|
||
{
|
||
if ($path === '' || !is_file($path) || !is_readable($path)) {
|
||
throw new PayException('微信支付商户私钥文件不存在,请重新上传未加密的 apiclient_key.pem');
|
||
}
|
||
|
||
$privateKey = @file_get_contents($path);
|
||
if ($privateKey === false || trim($privateKey) === '' || @openssl_pkey_get_private($privateKey) === false) {
|
||
throw new PayException('微信支付商户私钥无效,请重新上传未加密的 apiclient_key.pem');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建订单返回支付参数
|
||
* @param string $orderId
|
||
* @param string $totalFee
|
||
* @param string $attach
|
||
* @param string $body
|
||
* @param string $detail
|
||
* @param array $options
|
||
* @return array|false|mixed|string
|
||
*/
|
||
public function create(string $orderId, string $totalFee, string $attach, string $body, string $detail, array $options = [])
|
||
{
|
||
$this->authSetPayType($options);
|
||
$options['payment_context'] = $this->getPaymentContext($options);
|
||
if (($options['payment_context']['mode'] ?? '') !== 'mini_shop') {
|
||
$this->getPayClient($options['payment_context'])->setExpireTime((int)($options['expire_time'] ?? 0));
|
||
}
|
||
|
||
switch ($this->payType) {
|
||
case 'NATIVE':
|
||
$res = $this->getPayClient()->nativePay($orderId, $totalFee, $body, $attach);
|
||
$res['invalid'] = time() + 60;
|
||
// 支付二维码中间 logo:站点公用配置,允许 driver 直接 sys_config 读取
|
||
$res['logo'] = (string)sys_config('wap_login_logo');
|
||
return $res;
|
||
case 'APP':
|
||
return $this->getPayClient()->appPay($orderId, $totalFee, $body, $attach);
|
||
case 'JSAPI':
|
||
if (empty($options['openid'])) {
|
||
throw new PayException('缺少openid');
|
||
}
|
||
if (($options['platform'] ?? '') === 'wechat_mini') {
|
||
if ($options['payment_context']['mode'] === 'mini_shop') {
|
||
return WechatV3PaySdk::newJsPay($options['openid'], $orderId, $totalFee, $attach, $body, $detail, $options);
|
||
}
|
||
return $this->getPayClient()->miniprogPay($options['openid'], $orderId, $totalFee, $body, $attach);
|
||
}
|
||
return $this->getPayClient()->jsapiPay($options['openid'], $orderId, $totalFee, $body, $attach);
|
||
case 'h5':
|
||
return $this->getPayClient()->h5Pay($orderId, $totalFee, $body, $attach);
|
||
default:
|
||
throw new PayException('微信支付:支付类型错误');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param string $openid
|
||
* @param string $orderId
|
||
* @param string $amount
|
||
* @param array $options
|
||
* @return mixed
|
||
*/
|
||
public function merchantPay(string $openid, string $orderId, string $amount, array $options = [])
|
||
{
|
||
return $this->getPayClient()->setType($options['type'])->batches(
|
||
$orderId,
|
||
$amount,
|
||
$options['batch_name'],
|
||
$options['batch_remark'],
|
||
[
|
||
[
|
||
'out_detail_no' => $orderId,
|
||
'transfer_amount' => $amount,
|
||
'transfer_remark' => $options['batch_remark'],
|
||
'openid' => $openid
|
||
]
|
||
]
|
||
);
|
||
}
|
||
|
||
public function merchantPayNew($type, $order_id, $transfer_scene_id, $openid, $user_name, $transfer_amount, $transfer_remark, $notify_url, $user_recv_perception, $transfer_scene_report_infos)
|
||
{
|
||
return $this->getPayClient()->setType($type)->transferBills(
|
||
$order_id,
|
||
$transfer_scene_id,
|
||
$openid,
|
||
$user_name,
|
||
$transfer_amount,
|
||
$transfer_remark,
|
||
$notify_url,
|
||
$user_recv_perception,
|
||
$transfer_scene_report_infos
|
||
);
|
||
}
|
||
|
||
public function queryTransferBills($order_id)
|
||
{
|
||
return $this->getPayClient()->queryTransferBills((string)$order_id);
|
||
}
|
||
|
||
/**
|
||
* 查询旧批次转账明细,受理状态不可作为到账成功。
|
||
* @param string $orderNo 原转账单号
|
||
* @return array
|
||
*/
|
||
public function queryBatchTransfer(string $orderNo): array
|
||
{
|
||
return $this->getPayClient()->queryBatchTransfer($orderNo);
|
||
}
|
||
|
||
/**
|
||
* 发起退款
|
||
* @param string $outTradeNo
|
||
* @param array $options
|
||
* @return mixed
|
||
*/
|
||
public function refund(string $outTradeNo, array $options = [])
|
||
{
|
||
$context = $options['payment_context'] ?? [];
|
||
if (($context['mode'] ?? '') === 'mini_shop') {
|
||
return WechatV3PaySdk::refund((string)($options['order_id'] ?? $outTradeNo), $options);
|
||
}
|
||
return $this->getPayClient($context)->refund($outTradeNo, $options);
|
||
}
|
||
|
||
/**
|
||
* 查询退款
|
||
* @param string $outTradeNo
|
||
* @param string|null $outRequestNo
|
||
* @param array $other
|
||
* @return mixed
|
||
*/
|
||
public function queryRefund(string $outTradeNo, ?string $outRequestNo = null, array $other = [])
|
||
{
|
||
$context = $other['payment_context'] ?? [];
|
||
if (($context['mode'] ?? '') === 'mini_shop') {
|
||
return WechatV3PaySdk::queryRefund((string)($other['order_id'] ?? ''), $outRequestNo ?? $outTradeNo, $context);
|
||
}
|
||
return $this->getPayClient($context)->queryRefund($outTradeNo);
|
||
}
|
||
|
||
/**
|
||
* 查询原支付结果。
|
||
* @param string $orderNo 原订单号
|
||
* @param array $context 原支付信息
|
||
* @return array
|
||
*/
|
||
public function queryPayment(string $orderNo, array $context): array
|
||
{
|
||
return ($context['mode'] ?? '') === 'mini_shop' ? WechatV3PaySdk::getOrder($orderNo, $context)
|
||
: $this->getPayClient($context)->queryOrder($orderNo);
|
||
}
|
||
|
||
/**
|
||
* 关闭未支付订单。
|
||
* @param string $orderNo 原订单号
|
||
* @param array $context 原支付信息
|
||
* @return bool
|
||
*/
|
||
public function closePayment(string $orderNo, array $context): bool
|
||
{
|
||
if (($context['mode'] ?? '') === 'mini_shop') {
|
||
$result = WechatV3PaySdk::getOrder($orderNo, $context);
|
||
if (empty($result['transaction_id']) && !empty($context['expires_at'])
|
||
&& time() > (int)$context['expires_at'] + 60) return true;
|
||
throw new PayException('新版小程序订单需等待支付过期并查询确认后取消');
|
||
}
|
||
return $this->getPayClient($context)->closeOrder($orderNo);
|
||
}
|
||
|
||
/**
|
||
* @param string $type 回调类型(预留,V3统一处理)
|
||
* @return mixed|\think\Response
|
||
*/
|
||
public function handleNotify(string $type = '')
|
||
{
|
||
return $this->getPayClient()->handleNotify(function ($message, $eventType) {
|
||
if ($eventType === 'REFUND.SUCCESS') {
|
||
$merchantId = (string)($message['mchid'] ?? $message['sp_mchid'] ?? '');
|
||
$subMerchantId = (string)($message['sub_mchid'] ?? '');
|
||
$expectedSubMerchant = !empty($this->payment['mer_type'])
|
||
? trim((string)($this->payment['pay_sub_merchant_id'] ?? '')) : '';
|
||
if (($message['refund_status'] ?? '') !== 'SUCCESS'
|
||
|| $merchantId === ''
|
||
|| $merchantId !== trim((string)($this->payment['pay_weixin_mchid'] ?? ''))
|
||
|| $subMerchantId !== $expectedSubMerchant) {
|
||
return false;
|
||
}
|
||
// 验签解密后只传递退款核对字段,不记录收款账户等个人信息。
|
||
return Event::until('Notify', [[
|
||
'event_type' => $eventType,
|
||
'out_refund_no' => $message['out_refund_no'] ?? '',
|
||
'out_trade_no' => $message['out_trade_no'] ?? '',
|
||
'refund_id' => $message['refund_id'] ?? '',
|
||
'refund_status' => 'SUCCESS',
|
||
'mchid' => $merchantId,
|
||
'sub_mchid' => $subMerchantId,
|
||
'amount' => (array)($message['amount'] ?? []),
|
||
], self::WEIXIN_PAY_CHANNEL_KEY]);
|
||
}
|
||
if ($eventType === 'TRANSACTION.SUCCESS') {
|
||
$merchantId = (string)($message['mchid'] ?? $message['sp_mchid'] ?? '');
|
||
$subMerchantId = (string)($message['sub_mchid'] ?? '');
|
||
$expectedSubMerchant = !empty($this->payment['mer_type'])
|
||
? trim((string)($this->payment['pay_sub_merchant_id'] ?? '')) : '';
|
||
if (($message['trade_state'] ?? '') !== 'SUCCESS' || $merchantId === ''
|
||
|| $merchantId !== trim((string)($this->payment['pay_weixin_mchid'] ?? ''))
|
||
|| $subMerchantId !== $expectedSubMerchant || empty($message['transaction_id'])) {
|
||
return false;
|
||
}
|
||
$data = [
|
||
'attach' => $message['attach'] ?? '',
|
||
'out_trade_no' => $message['out_trade_no'] ?? '',
|
||
'transaction_id' => $message['transaction_id'],
|
||
'payment_mode' => 'v3',
|
||
'appid' => $message['sub_appid'] ?? $message['appid'] ?? $message['sp_appid'] ?? '',
|
||
'openid' => $message['payer']['sub_openid'] ?? $message['payer']['openid'] ?? '',
|
||
'mchid' => $merchantId,
|
||
'sub_mchid' => $subMerchantId,
|
||
'amount' => (array)($message['amount'] ?? []),
|
||
];
|
||
return Event::until('Notify', [$data, self::WEIXIN_PAY_CHANNEL_KEY]);
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
public function handleTransferNotify()
|
||
{
|
||
return $this->getPayClient()->handleTransferNotify(function ($message, $eventType) {
|
||
if ($eventType === 'MCHTRANSFER.BILL.FINISHED'
|
||
|| ($message['state'] ?? '') === 'SUCCESS'
|
||
|| ($message['transfer_state'] ?? '') === 'SUCCESS') {
|
||
$data = [
|
||
'out_bill_no' => $message['out_bill_no'] ?? '',
|
||
'transfer_bill_no' => $message['transfer_bill_no'] ?? '',
|
||
'state' => $message['state'] ?? $message['transfer_state'] ?? '',
|
||
'fail_reason' => $message['fail_reason'] ?? '',
|
||
'mchid' => $message['mchid'] ?? '',
|
||
'appid' => $message['appid'] ?? '',
|
||
'openid' => $message['openid'] ?? '',
|
||
'transfer_amount' => $message['transfer_amount'] ?? null,
|
||
];
|
||
return Event::until('Notify', [$data, self::WEIXIN_PAY_CHANNEL_KEY]);
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
}
|