You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
yanzong/app/common/library/payment/gateway/driver/wechat/V3.php

639 lines
22 KiB

<?php
// +----------------------------------------------------------------------
// | 萤火商城系统 [ 致力于通过产品和服务,帮助商家高效化开拓市场 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2017~2023 https://www.yiovo.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed 这不是一个自由软件,不允许对程序代码以任何形式任何目的的再发行
// +----------------------------------------------------------------------
// | Author: 萤火科技 <admin@yiovo.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\library\payment\gateway\driver\wechat;
use WeChatPay\Builder;
use WeChatPay\Formatter;
use WeChatPay\Crypto\Rsa;
use WeChatPay\Crypto\AesGcm;
use WeChatPay\Util\PemUtil;
use app\common\library\Log;
use app\common\library\helper;
use app\common\enum\Client as ClientEnum;
use cores\traits\ErrorTrait;
use cores\exception\BaseException;
use Psr\Http\Message\ResponseInterface;
/**
* 微信支付驱动 [V3]
* Class Wechat
* @package app\common\library\payment\gateway\driver
*/
class V3
{
use ErrorTrait;
/**
* 支付的客户端
* @var string|null
*/
protected ?string $client = null;
/**
* 支付配置参数
* @var array
*/
protected array $config = [];
// 统一下单API的返回结果
private array $result;
// 异步通知的请求参数 (由第三方支付发送)
private array $notifyParams;
/**
* 设置支付配置参数
* @param array $options 配置信息
* @param string $client 下单客户端
* @return static|null
*/
public function setOptions(array $options, string $client): ?V3
{
$this->client = $client ?: null;
$this->config = $this->getConfig($options);
return $this;
}
/**
* 统一下单API
* @param string $outTradeNo 交易订单号
* @param string $totalFee 实际付款金额
* @param array $extra 附加的数据 (需要携带openid)
* @return bool
* @throws BaseException
*/
public function unify(string $outTradeNo, string $totalFee, array $extra = []): bool
{
// 下单的参数
$params = [
'out_trade_no' => $outTradeNo,
'description' => '线上商城商品',
'notify_url' => $this->notifyUrl(), // 支付结果异步通知地址
'amount' => ['total' => (int)helper::bcmul($totalFee, 100), 'currency' => 'CNY'],
'scene_info' => ['payer_client_ip' => \request()->ip()]
];
// 普通商户参数和服务商支付参数
if ($this->isProvider()) {
$params['sp_appid'] = $this->config['app_id'];
$params['sp_mchid'] = $this->config['mch_id'];
$params['sub_appid'] = $this->config['sub_appid'];
$params['sub_mchid'] = $this->config['sub_mchid'];
} else {
$params['appid'] = $this->config['app_id'];
$params['mchid'] = $this->config['mch_id'];
}
// 用户的openid (只有JSAPI支付时需要)
if ($this->tradeType() === 'jsapi') {
$params['payer'][$this->isProvider() ? 'sub_openid' : 'openid'] = $extra['openid'];
}
// H5info
if ($this->tradeType() === 'h5') {
$params['scene_info']['h5_info'] = ['type' => 'Wap'];
}
try {
// 统一下单API
// Doc: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_1.shtml
$resp = $this->getApp()
->chain($this->getUnifyApiUrl())
->post(['json' => $params]);
// 记录api返回的数据
$unifyResult = helper::jsonDecode((string)$resp->getBody());
$this->result = $unifyResult;
// 生成app支付的配置
if ($this->client === ClientEnum::APP) {
$this->result = $this->appConfig($unifyResult['prepay_id']);
}
// 生成jssdk支付的配置
if (in_array($this->client, [ClientEnum::WXOFFICIAL, ClientEnum::MP_WEIXIN])) {
$this->result = $this->bridgeConfig($unifyResult['prepay_id']);
}
// 记录商户订单号
$this->result['out_trade_no'] = $outTradeNo;
// 记录日志
Log::append('Wechat-unify', [
'client' => $this->client,
'params' => $params,
'extra' => $extra,
'result' => $this->result
]);
return true;
} catch (\Throwable $e) {
// 异常处理
$message = $this->getThrowMessage($e);
$this->throwError('unify', "微信支付API下单失败:{$message}");
}
return false;
}
/**
* 交易查询 (主动查询订单支付状态)
* @param string $outTradeNo 交易订单号
* @return array|null
* @throws BaseException
*/
public function tradeQuery(string $outTradeNo): ?array
{
// 下单的参数
$params = [];
// 普通商户参数和服务商支付参数
if ($this->isProvider()) {
$params['sp_mchid'] = $this->config['mch_id'];
$params['sub_mchid'] = $this->config['sub_mchid'];
} else {
$params['mchid'] = $this->config['mch_id'];
}
try {
// 订单查询API
// Doc: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_2.shtml
$resp = $this->getApp()
->chain($this->getTradeApiUrl($outTradeNo))
->get(['query' => $params]);
// 记录api返回的数据
$result = helper::jsonDecode((string)$resp->getBody());
// 记录日志
Log::append('Wechat-tradeQuery', ['outTradeNo' => $outTradeNo, 'result' => $result]);
// 判断订单支付成功
return [
// 支付状态: true成功 false失败
'paySuccess' => $result['trade_state'] === 'SUCCESS',
// 第三方交易流水号
'tradeNo' => $result['transaction_id'] ?? ''
];
} catch (\Throwable $e) {
// 异常处理
$message = $this->getThrowMessage($e);
$this->throwError('tradeQuery', "微信支付交易查询失败:{$message}");
}
return null;
}
/**
* 支付成功后的异步通知
* @param string $apiv3Key 微信支付v3秘钥
* @param string $platformCertificateFilePath 平台证书路径
* @return bool|string
*/
public function notify(string $apiv3Key, string $platformCertificateFilePath)
{
// 微信异步通知参数
$header = \request()->header();
$inBody = file_get_contents('php://input');
// 微信支付平台证书
$platformPublicKeyInstance = Rsa::from("file://{$platformCertificateFilePath}", Rsa::KEY_TYPE_PUBLIC);
// 检查通知时间偏移量,允许5分钟之内的偏移
// $timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp);
$timeOffsetStatus = true;
$verifiedStatus = Rsa::verify(
// 构造验签名串
Formatter::joinedByLineFeed($header['wechatpay-timestamp'], $header['wechatpay-nonce'], $inBody),
$header['wechatpay-signature'],
$platformPublicKeyInstance
);
if ($timeOffsetStatus && $verifiedStatus) {
// 转换通知的JSON文本消息为PHP Array数组
$inBodyArray = (array)json_decode($inBody, true);
// 使用PHP7的数据解构语法,从Array中解构并赋值变量
['resource' => [
'ciphertext' => $ciphertext,
'nonce' => $nonce,
'associated_data' => $aad
]] = $inBodyArray;
// 加密文本消息解密
$inBodyResource = AesGcm::decrypt($ciphertext, $apiv3Key, $nonce, $aad);
// 把解密后的文本转换为PHP Array数组
$this->notifyParams = helper::jsonDecode($inBodyResource);
// 记录日志
Log::append('Wechat-notify', ['message' => '微信异步回调验证成功']);
return $this->notifyParams['out_trade_no'];
}
return false;
}
/**
* 微信支付退款API
* @param string $outTradeNo 第三方交易单号
* @param string $refundAmount 退款金额
* @param array $extra 附加数据 (需要携带订单付款总金额)
* @return bool
* @throws BaseException
*/
public function refund(string $outTradeNo, string $refundAmount, array $extra = []): bool
{
// 下单的参数
$params = [
'out_trade_no' => $outTradeNo,
'out_refund_no' => time() . '-' . uniqid(),
'amount' => [
'refund' => (int)helper::bcmul($refundAmount, 100),
'total' => (int)helper::bcmul($extra['totalFee'], 100),
'currency' => 'CNY',
],
];
// 普通商户参数和服务商支付参数
if ($this->isProvider()) {
$params['sub_mchid'] = $this->config['sub_mchid'];
}
try {
// 申请退款API
// Doc: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_3_9.shtml
$resp = $this->getApp()
->chain($this->getRefundApiUrl())
->post(['json' => $params]);
// 记录api返回的数据
$result = helper::jsonDecode((string)$resp->getBody());
// 记录日志
Log::append('Wechat-refund', [
'outTradeNo' => $outTradeNo,
'refundAmount' => $refundAmount,
'result' => $result
]);
// 请求成功
return true;
} catch (\Throwable $e) {
// 异常处理
$message = $this->getThrowMessage($e);
$this->throwError('tradeQuery', "微信退款api请求失败:{$message}");
}
return false;
}
/**
* 商家转账到零钱API
* @param string $outTradeNo 交易订单号
* @param string $totalFee 实际付款金额
* @param array $extra 附加的数据 (需要携带openid、desc)
* @return bool
* @throws BaseException
*/
public function transfers(string $outTradeNo, string $totalFee, array $extra = []): bool
{
// 下单的参数
$params = [
'appid' => $this->config['app_id'],
'out_batch_no' => $outTradeNo,
'batch_name' => $extra['desc'],
'batch_remark' => $extra['desc'],
'total_amount' => (int)helper::bcmul($totalFee, 100), // 转账金额,单位:分
'total_num' => 1, // 转账总笔数
'transfer_detail_list' => [
[
'out_detail_no' => time() . uniqid(),
'transfer_amount' => (int)helper::bcmul($totalFee, 100),
'transfer_remark' => $extra['desc'],
'openid' => $extra['openid'],
]
]
];
try {
// 商家转账到零钱API
// Doc: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter4_3_1.shtml
$resp = $this->getApp()
->chain($this->getTransfersUrl())
->post(['json' => $params]);
// 记录api返回的数据
$result = helper::jsonDecode((string)$resp->getBody());
// 记录日志
Log::append('Wechat-transfers', ['outTradeNo' => $outTradeNo, 'result' => $result]);
// 请求成功
return true;
} catch (\Throwable $e) {
// 异常处理
$message = $this->getThrowMessage($e);
$this->throwError('transfers', "商家转账到零钱api请求失败:{$message}");
}
return false;
}
/**
* 获取异步回调的请求参数
* @return array
*/
public function getNotifyParams(): array
{
return [
// 第三方交易流水号
'tradeNo' => $this->notifyParams['transaction_id']
];
}
/**
* 返回异步通知结果的输出内容
* @return string
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
*/
public function getNotifyResponse(): string
{
return 'SUCCESS';
}
/**
* 返回统一下单API的结果 (用于前端)
* @return array
* @throws BaseException
*/
public function getUnifyResult(): array
{
if (empty($this->result)) {
$this->throwError('getUnifyResult', '当前没有unify结果');
}
// 允许输出的字段 (防止泄露敏感信息)
$result = helper::pick($this->result, [
'out_trade_no',
'nonce_str', 'prepay_id', 'sign', 'trade_type', 'mweb_url', 'h5_url',
'appid', 'partnerid', 'noncestr', 'prepayid', 'timestamp', 'package', 'sign',
'appId', 'timeStamp', 'nonceStr', 'package', 'signType', 'paySign',
]);
// 当前的时间戳
$result['time_stamp'] = (string)time();
return $result;
}
/**
* 设置异步通知的错误信息
* @param string $error 错误信息
* @param bool $outputFail 是否输出fail信息 (会使微信服务器重复发起通知)
*/
private function notifyPaidError(string $error, bool $outputFail = true)
{
}
/**
* 输出错误信息
* @param string $action 当前的操作
* @param string $errMessage 错误信息
* @throws BaseException
*/
private function throwError(string $action, string $errMessage)
{
$this->error = $errMessage;
Log::append("Wechat-{$action}", ['errMessage' => $errMessage]);
throwError($errMessage);
}
/**
* 根据客户端选择对应的微信支付方式
* @return string
* @throws BaseException
*/
private function tradeType(): string
{
$tradeTypes = [
ClientEnum::H5 => 'h5',
ClientEnum::WXOFFICIAL => 'jsapi',
ClientEnum::MP_WEIXIN => 'jsapi',
ClientEnum::APP => 'app'
];
if (!isset($tradeTypes[$this->client])) {
$this->throwError('tradeType', '未找到当前客户端适配的微信支付方式');
}
return $tradeTypes[$this->client];
}
/**
* 请求错误时错误信息
*/
private function resultError(ResponseInterface $resp)
{
}
/**
* 获取微信支付应用类
* @return \WeChatPay\BuilderChainable
* @throws BaseException
*/
private function getApp(): \WeChatPay\BuilderChainable
{
// 从本地文件中加载「商户API私钥」,「商户API私钥」会用来生成请求的签名
$merchantPrivateKeyInstance = $this->getMerchantPrivateKeyInstance();
// 从本地文件中加载「微信支付平台证书」,用来验证微信支付应答的签名
$platformCertificateFilePath = "file://{$this->config['platform_cert_path']}";
try {
$platformPublicKeyInstance = Rsa::from($platformCertificateFilePath, Rsa::KEY_TYPE_PUBLIC);
} catch (\UnexpectedValueException $e) {
$platformPublicKeyInstance = null;
throwError('证书文件(PLATFORM)不正确');
}
// 从「微信支付平台证书」中获取「证书序列号」
$platformCertificateSerial = PemUtil::parseCertificateSerialNo($platformCertificateFilePath);
// 构造一个 APIv3 客户端实例
return Builder::factory([
// 微信支付商户号
'mchid' => $this->config['mch_id'],
// 「商户API证书」的「证书序列号」
'serial' => $this->serialno($this->config['cert_path']),
'privateKey' => $merchantPrivateKeyInstance,
'certs' => [
// 从「微信支付平台证书」中获取「证书序列号」
$platformCertificateSerial => $platformPublicKeyInstance,
],
]);
}
/**
* 从本地文件中加载「商户API私钥」,「商户API私钥」会用来生成请求的签名
* @return mixed|\OpenSSLAsymmetricKey|resource
* @throws BaseException
*/
private function getMerchantPrivateKeyInstance()
{
try {
return Rsa::from("file://{$this->config['key_path']}", Rsa::KEY_TYPE_PRIVATE);
} catch (\UnexpectedValueException $e) {
throwError('证书文件(KEY)不正确');
}
return null;
}
/**
* 读取公钥中的序列号
* @param string $publicKey
* @return mixed
* @throws BaseException
*/
private function serialno(string $publicKey)
{
$content = file_get_contents($publicKey);
$plaintext = !empty($content) ? openssl_x509_parse($content) : false;
empty($plaintext) && throwError('证书文件(CERT)不正确');
return $plaintext['serialNumberHex'];
}
/**
* 构建微信支付配置
* @return string[]
*/
private function getConfig($options): array
{
if ($options['mchType'] === 'provider') {
return [
'mch_type' => 'provider',
'app_id' => $options['provider']['spAppId'],
'mch_id' => $options['provider']['spMchId'],
'key' => $options['provider']['spApiKey'],
'cert_path' => $options['provider']['spApiclientCertPath'],
'key_path' => $options['provider']['spApiclientKeyPath'],
'platform_cert_path' => $options['provider']['platformCertPath'],
'sub_mchid' => $options['provider']['subMchId'],
'sub_appid' => $options['provider']['subAppId'],
];
} else {
return [
'mch_type' => 'normal',
'app_id' => $options['normal']['appId'],
'mch_id' => $options['normal']['mchId'],
'key' => $options['normal']['apiKey'],
'cert_path' => $options['normal']['apiclientCertPath'],
'key_path' => $options['normal']['apiclientKeyPath'],
'platform_cert_path' => $options['normal']['platformCertPath'],
];
}
}
/**
* 异步回调地址
* @return string
*/
private function notifyUrl(): string
{
// 例如:https://www.xxxx.com/wxpayNoticeV3.php
return base_url() . 'wxpayNoticeV3.php';
}
/**
* 当前是否为服务商模式
* @return bool
*/
private function isProvider(): bool
{
return $this->config['mch_type'] === 'provider';
}
/**
* Generate app payment parameters.
* @param string $prepayId
* @return array
* @throws BaseException
*/
private function appConfig(string $prepayId): array
{
$params = [
'appid' => $this->config['app_id'],
'partnerid' => $this->config['mch_id'],
'prepayid' => $prepayId,
'noncestr' => Formatter::nonce(),
'timestamp' => (string)Formatter::timestamp(),
'package' => 'Sign=WXPay',
];
$params += ['sign' => Rsa::sign(
Formatter::joinedByLineFeed(...array_values($params)),
$this->getMerchantPrivateKeyInstance()
)];
return $params;
}
/**
* [WeixinJSBridge] Generate js config for payment.
*
* <pre>
* WeixinJSBridge.invoke(
* 'getBrandWCPayRequest',
* ...
* );
* </pre>
*
* @param string $prepayId
* @return string|array
* @throws BaseException
*/
private function bridgeConfig(string $prepayId)
{
$params = [
'appId' => $this->isProvider() ? $this->config['sub_appid'] : $this->config['app_id'],
'timeStamp' => (string)Formatter::timestamp(),
'nonceStr' => Formatter::nonce(),
'package' => "prepay_id=$prepayId",
];
$params += ['paySign' => Rsa::sign(
Formatter::joinedByLineFeed(...array_values($params)),
$this->getMerchantPrivateKeyInstance()
), 'signType' => 'RSA'];
return $params;
}
/**
* 处理API的异常
* @param \Throwable $e
* @return mixed|string
*/
private function getThrowMessage(\Throwable $e)
{
$message = $e->getMessage();
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$body = (string)$e->getResponse()->getBody();
if (!empty($body)) {
$result = helper::jsonDecode($body);
isset($result['message']) && $message = $result['message'];
}
}
return $message;
}
/**
* 统一下单API的Url [需判断是否为服务商支付以及客户端]
* @return string
* @throws BaseException
*/
private function getUnifyApiUrl(): string
{
$partnerNodo = $this->isProvider() ? 'partner/' : '';
return "v3/pay/{$partnerNodo}transactions/" . $this->tradeType();
}
/**
* 订单查询API的Url [需判断是否为服务商支付以及客户端]
* @param string $outTradeNo
* @return string
*/
private function getTradeApiUrl(string $outTradeNo): string
{
$partnerNodo = $this->isProvider() ? 'partner/' : '';
return "v3/pay/{$partnerNodo}transactions/out-trade-no/{$outTradeNo}";
}
/**
* 申请退款API的Url
* @return string
*/
private function getRefundApiUrl(): string
{
return 'v3/refund/domestic/refunds';
}
/**
* 商家转账到零钱API的Url
* @return string
*/
private function getTransfersUrl(): string
{
return 'v3/transfer/batches';
}
}