jh-admin/addon/wechatpay/model/V3.php

673 lines
28 KiB
PHP
Raw Permalink 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
/**
* SaaSMall商城系统 - 团队十年电商经验汇集巨献!
* =========================================================
* Copy right 2019-2029 成都SAAS云科技有限公司, 保留所有权利。
* ----------------------------------------------
* 官方网址: https://www.gobuysaas.com
* =========================================================
*/
namespace addon\wechatpay\model;
use app\model\BaseModel;
use app\model\system\Cron;
use app\model\system\Pay as PayCommon;
use app\model\upload\Upload;
use think\Exception;
use think\exception\HttpException;
use think\facade\Cache;
use think\facade\Log;
use WeChatPay\Builder;
use WeChatPay\ClientDecoratorInterface;
use WeChatPay\Crypto\AesGcm;
use WeChatPay\Crypto\Rsa;
use WeChatPay\Formatter;
use WeChatPay\Util\MediaUtil;
use WeChatPay\Util\PemUtil;
use GuzzleHttp\Middleware;
use Psr\Http\Message\ResponseInterface;
/**
* 微信支付v3支付
* 版本 1.0.4
*/
class V3 extends BaseModel
{
/**
* 应用实例
* @var \WeChatPay\BuilderChainable
*/
private $app;
/**
* @var 平台证书实例
*/
private $plateform_certificate_instance;
/**
* @var 平台证书序列号
*/
private $plateform_certificate_serial;
/**
* 微信支付配置
*/
private $config;
public function __construct($config)
{
$this->config = $config;
// 判断:是否为服务商模式 进行对应的处理
if (array_key_exists('sub_mch_id', $config) && !empty($config['sub_mch_id'])) {
// 服务商模式
$merchant_certificate_instance = openssl_x509_read($config['apiclient_cert_text']);
} else {
// 普通商户模式
$merchant_certificate_instance = PemUtil::loadCertificate(realpath($config['apiclient_cert']) ?: getCertPath($config['apiclient_cert_text'], $config['apiclient_cert']));
}
// 证书序列号
$merchant_certificate_serial = PemUtil::parseCertificateSerialNo($merchant_certificate_instance);
// 检测平台证书是否存在
if (empty($config['plateform_cert']) || !realpath($this->config['plateform_cert'])) {
$create_res = $this->certificates();
// if ($create_res['code'] != 0) throw new ApiException(-1, $create_res['message']);
// 保存平台证书
$this->config['plateform_cert'] = $create_res['data']['cert_path'];
$this->config['plateform_cert_text'] = file_get_contents($create_res['data']['cert_path']);
(new Config())->setPayConfig($this->config, $this->config['site_id']);
}
// 加载平台证书
$this->plateform_certificate_instance = PemUtil::loadCertificate(realpath($this->config['plateform_cert']) ?: getCertPath($this->config['plateform_cert_text'], $this->config['plateform_cert']));
// 平台证书序列号
$this->plateform_certificate_serial = PemUtil::parseCertificateSerialNo($this->plateform_certificate_instance);
// 接口基本信息配置
$this->app = Builder::factory([
// 商户号
'mchid' => $config['mch_id'],
// 商户证书序列号
'serial' => $merchant_certificate_serial,
// 商户API私钥
'privateKey' => PemUtil::loadPrivateKey(realpath($config['apiclient_key'])),
'certs' => [
$this->plateform_certificate_serial => $this->plateform_certificate_instance
]
]);
}
/**
* 生成平台证书
*/
private function certificates()
{
try {
$merchant_certificate_instance = PemUtil::loadCertificate(realpath($this->config['apiclient_cert']));
// 证书序列号
$merchant_certificate_serial = PemUtil::parseCertificateSerialNo($merchant_certificate_instance);
$certs = [
'any' => null
];
$app = Builder::factory([
// 商户号
'mchid' => $this->config['mch_id'],
// 商户证书序列号
'serial' => $merchant_certificate_serial,
// 商户API私钥
'privateKey' => PemUtil::loadPrivateKey(realpath($this->config['apiclient_key'])),
'certs' => &$certs,
// 'debug' => true
]);
$stack = $app->getDriver()->select(ClientDecoratorInterface::JSON_BASED)->getConfig('handler');
$stack->after('verifier', Middleware::mapResponse(self::certsInjector($this->config['v3_pay_signkey'], $certs)), 'injector');
$stack->before('verifier', Middleware::mapResponse(self::certsRecorder((string)dirname($this->config['apiclient_key']), $certs)), 'recorder');
$param = [
'url' => '/v3/certificates',
'timestamp' => (string)Formatter::timestamp(),
'noncestr' => uniqid()
];
$resp = $app->chain("v3/certificates")
->get([
'headers' => [
'Authorization' => Rsa::sign(
Formatter::joinedByLineFeed(...array_values($param)),
Rsa::from('file://' . realpath($this->config['apiclient_key']))
)
]
]);
$result = json_decode($resp->getBody()->getContents(), true);
$file_path = dirname($this->config['apiclient_key']) . '/plateform_cert.pem';
return $this->success(['cert_path' => $file_path]);
} catch (\Exception $e) {
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$result = json_decode($e->getResponse()->getBody()->getContents(), true);
return $this->error($result, $result['message']);
} else {
return $this->error([], $e->getMessage());
}
}
}
private static function certsInjector(string $apiv3Key, array &$certs): callable
{
return static function (ResponseInterface $response) use ($apiv3Key, &$certs): ResponseInterface {
$body = (string)$response->getBody();
/** @var object{data:array<object{encrypt_certificate:object{serial_no:string,nonce:string,associated_data:string}}>} $json */
$json = \json_decode($body);
$data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
\array_map(static function ($row) use ($apiv3Key, &$certs) {
$cert = $row->encrypt_certificate;
$certs[$row->serial_no] = AesGcm::decrypt($cert->ciphertext, $apiv3Key, $cert->nonce, $cert->associated_data);
}, $data);
return $response;
};
}
private static function certsRecorder(string $outputDir, array &$certs): callable
{
return static function (ResponseInterface $response) use ($outputDir, &$certs): ResponseInterface {
$body = (string)$response->getBody();
/** @var object{data:array<object{effective_time:string,expire_time:string:serial_no:string}>} $json */
$json = \json_decode($body);
$data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
\array_walk($data, static function ($row, $index, $certs) use ($outputDir) {
$serialNo = $row->serial_no;
$outpath = $outputDir . \DIRECTORY_SEPARATOR . 'plateform_cert.pem';
\file_put_contents($outpath, $certs[$serialNo]);
}, $certs);
return $response;
};
}
/**
* 支付
* @param array $param
* @return array
*/
public function pay(array $param)
{
$self = $this;
$site_id = $param['site_id'];
$data = [
'json' => [
'appid' => $this->config['app_id'],
'mchid' => $this->config['mch_id'],
'description' => str_sub($param["pay_body"], 15),
'out_trade_no' => $param["out_trade_no"],
'notify_url' => $param["notify_url"],
'amount' => [
'total' => round($param["pay_money"] * 100)
]
]
];
if ($this->config['is_isp']) {
$data['json']['sp_appid'] = $this->config['sp_appid'];
$data['json']['sp_mchid'] = $this->config['sp_mchid'];
$data['json']['sub_appid'] = $this->config['sub_appid'];
$data['json']['sub_mchid'] = $this->config['sub_mchid'];
$data['json']['payer'] = ['sub_openid' => $param['sub_openid']];
unset($data['json']['appid'], $data['json']['mchid']);
} else {
$data['json']['payer'] = ['openid' => $param['openid']];
}
$res = event('checkAccountsAuth', ['site_id' => $this->config['site_id'], 'pay_type' => 'wechatpay','out_trade_no'=>$param["out_trade_no"]]);//检查分账状态
if ($res && array_sum(array_column($res,'isDivide'))) {
$divideState=array_sum(array_column($res,'divideState'));
$data['json']['settle_info']['profit_sharing'] = true; //判断是否参与分账
if (!$divideState) return $this->error('', implode(',', array_column($res,'err_msg')));
}
switch ($param["trade_type"]) {
case 'JSAPI':
$data['trade_type'] = 'jsapi';
$data['callback'] = function ($result) use ($self) {
return success(0, '', [
"type" => "jsapi",
"data" => $self->jsskdConfig($result['prepay_id'])
]);
};
break;
case 'APPLET':
$data['trade_type'] = 'jsapi';
$data['callback'] = function ($result) use ($self) {
return success(0, '', [
"type" => "jsapi",
"data" => $self->jsskdConfig($result['prepay_id'])
]);
};
break;
case 'NATIVE':
$data['trade_type'] = 'native';
$data['callback'] = function ($result) use ($site_id) {
$upload_model = new Upload($site_id);
$qrcode_result = $upload_model->qrcode($result['code_url']);
return success(0, '', [
"type" => "qrcode",
"qrcode" => $qrcode_result['data'] ?? ''
]);
};
break;
case 'MWEB':
$data['trade_type'] = 'h5';
$data['json']['scene_info'] = [
'payer_client_ip' => request()->ip(),
'h5_info' => [
'type' => 'Wap'
]
];
$data['callback'] = function ($result) {
return success(0, '', [
"type" => "url",
"url" => $result['h5_url']
]);
};
break;
case 'APP':
$data['trade_type'] = 'app';
$data['callback'] = function ($result) use ($self) {
return success(0, '', [
"type" => "app",
"data" => $self->appConfig($result['prepay_id'])
]);
};
break;
}
$result = $this->unify($data);
if ($result['code'] != 0) return $result;
$result = $data['callback']($result['data']);
return $result;
}
/**
* 统一下单接口
* @param array $param
*/
public function unify(array $param)
{
try {
if ($this->config['is_isp']) {
$url = 'v3/pay/partner/transactions/' . $param['trade_type'];
} else {
$url = 'v3/pay/transactions/' . $param['trade_type'];
}
$resp = $this->app->chain($url)->post([
'json' => $param['json']
]);
$result = json_decode($resp->getBody()->getContents(), true);
return $this->success($result);
} catch (\Exception $e) {
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$result = json_decode($e->getResponse()->getBody()->getContents(), true);
return $this->error($result, $result['message']);
} else {
return $this->error([], $e->getMessage());
}
}
}
/**
* 生成支付配置
* @param string $prepay_id
*/
private function jsskdConfig(string $prepay_id)
{
$param = [
'appId' => $this->config['app_id'],
'timeStamp' => (string)Formatter::timestamp(),
'nonceStr' => uniqid(),
'package' => "prepay_id=$prepay_id"
];
$param += ['paySign' => Rsa::sign(
Formatter::joinedByLineFeed(...array_values($param)),
Rsa::from('file://' . realpath($this->config['apiclient_key']))
), 'signType' => 'RSA'];
return $param;
}
/**
* 生成支付配置
* @param string $prepay_id
* @return array
*/
private function appConfig(string $prepay_id)
{
$param = [
'appid' => $this->config['app_id'],
'timestamp' => (string)Formatter::timestamp(),
'noncestr' => uniqid(),
'prepayid' => $prepay_id
];
$param += [
'sign' => Rsa::sign(
Formatter::joinedByLineFeed(...array_values($param)),
Rsa::from('file://' . realpath($this->config['apiclient_key']))
),
'package' => 'Sign=WXPay',
'partnerid' => $this->config['mch_id']
];
return $param;
}
/**
* 异步回调
*/
public function payNotify()
{
$inWechatpaySignature = request()->header('Wechatpay-Signature'); // 从请求头中拿到 签名
$inWechatpayTimestamp = request()->header('Wechatpay-Timestamp'); // 从请求头中拿到 时间戳
$inWechatpaySerial = request()->header('Wechatpay-Serial'); // 从请求头中拿到 时间戳
$inWechatpayNonce = request()->header('Wechatpay-Nonce'); // 从请求头中拿到 时间戳
$inBody = file_get_contents('php://input');
$platformPublicKeyInstance = Rsa::from('file://' . realpath($this->config['plateform_cert']), Rsa::KEY_TYPE_PUBLIC);
$timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp);
$verifiedStatus = Rsa::verify(
// 构造验签名串
Formatter::joinedByLineFeed($inWechatpayTimestamp, $inWechatpayNonce, file_get_contents('php://input')),
$inWechatpaySignature,
$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, $this->config['v3_pay_signkey'], $nonce, $aad);
// 把解密后的文本转换为PHP Array数组
$message = json_decode($inBodyResource, true);
Log::write('message' . $inBodyResource);
// 交易状态为成功
if (isset($message['trade_state']) && $message['trade_state'] == 'SUCCESS') {
if (isset($message['out_trade_no'])) {
$pay_common = new PayCommon();
$pay_info = $pay_common->getPayInfo($message['out_trade_no'])['data'];
if (empty($pay_info)) return;
if ($message['amount']['total'] != round($pay_info['pay_money'] * 100)) return;
// 用户是否支付成功
$pay_common->onlinePay($message['out_trade_no'], "wechatpay", $message["transaction_id"], "wechatpay");
header('', '', 200);
}
} else {
throw new HttpException(500, '失败', null, [], 'FAIL');
}
} else {
throw new HttpException(500, '失败', null, [], 'FAIL');
}
}
/**
* 支付单据关闭
* @param array $param
*/
public function payClose(array $param)
{
try {
$resp = $this->app->chain("v3/pay/transactions/out-trade-no/{$param['out_trade_no']}/close")->post([
'json' => [
'mchid' => $this->config['mch_id']
]
]);
$result = json_decode($resp->getBody()->getContents(), true);
return $this->success($result);
} catch (\Exception $e) {
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$result = json_decode($e->getResponse()->getBody()->getContents(), true);
if (isset($result['code']) && ($result['code'] == 'ORDERPAID' || $result['code'] == 'ORDER_PAID'))
return $this->error(['is_paid' => 1, 'pay_type' => 'wechatpay'], $result['code']);
return $this->error($result, $result['message']);
} else {
return $this->error([], $e->getMessage());
}
}
}
/**
* 申请退款
* @param array $param
*/
public function refund(array $param)
{
$pay_info = $param["pay_info"];
try {
$resp = $this->app->chain("v3/refund/domestic/refunds")->post([
'json' => [
'out_trade_no' => $pay_info['out_trade_no'],
'out_refund_no' => $param['refund_no'],
'notify_url' => addon_url("pay/pay/refundnotify"),
'amount' => [
'refund' => round($param['refund_fee'] * 100),
'total' => round($pay_info['pay_money'] * 100),
'currency' => $param['currency'] ?? 'CNY'
]
]
]);
$result = json_decode($resp->getBody()->getContents(), true);
if (isset($result['status']) && ($result['status'] == 'SUCCESS' || $result['status'] == 'PROCESSING'))
return $this->success($result);
else return $this->success($result, '退款异常');
} catch (\Exception $e) {
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$result = json_decode($e->getResponse()->getBody()->getContents(), true);
return $this->error($result, $result['message']);
} else {
return $this->error([], $e->getMessage());
}
}
}
/**
* 转账
* @param array $param
*/
public function transfer(array $param)
{
$data = [
'appid' => $this->config['app_id'],
'out_batch_no' => $param['out_trade_no'],
'batch_name' => '客户提现转账',
'batch_remark' => '客户提现转账提现交易号' . $param['out_trade_no'],
'total_amount' => round($param['amount'] * 100),
'total_num' => 1,
'transfer_detail_list' => [
[
'out_detail_no' => $param['out_trade_no'],
'transfer_amount' => $param['amount'] * 100,
'transfer_remark' => $param['desc'],
'openid' => $param['account_number'],
'user_name' => $this->encryptor($param['real_name'])
]
]
];
$this->app->chain('v3/transfer/batches')
->postAsync([
'json' => $data,
'headers' => [
'Wechatpay-Serial' => $this->plateform_certificate_serial
]
])->then(static function ($response) use (&$result) {
$result = json_decode($response->getBody()->getContents(), true);
$result = success(0, '', $result);
})->otherwise(static function ($exception) use (&$result) {
if ($exception instanceof \GuzzleHttp\Exception\RequestException && $exception->hasResponse()) {
$result = json_decode($exception->getResponse()->getBody()->getContents(), true);
$result = error(-1, $result['message'], $result);
} else {
$result = error(-1, $exception->getMessage());
}
})->wait();
return $result;
}
/**
* 查询转账明细
* @param string $out_batch_no
* @param string $out_detail_no
* @return array
*/
public function transferDetail(string $out_batch_no, string $out_detail_no): array
{
try {
$resp = $this->app->chain("v3/transfer/batches/out-batch-no/{$out_batch_no}/details/out-detail-no/{$out_detail_no}")
->get();
$result = json_decode($resp->getBody()->getContents(), true);
return $this->success($result);
} catch (\Exception $e) {
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$result = json_decode($e->getResponse()->getBody()->getContents(), true);
return $this->error($result, $result['message']);
} else {
return $this->error([], $e->getMessage());
}
}
}
/**
* 加密数据
* @param string $str
* @return string
*/
public function encryptor(string $str)
{
$publicKey = $this->plateform_certificate_instance;
// 加密方法
$encryptor = function ($msg) use ($publicKey) {
return Rsa::encrypt($msg, $publicKey);
};
return $encryptor($str);
}
/**
* 获取转账结果
* @param $id
* @return array
*/
public function getTransferResult($withdraw_info)
{
$result = $this->transferDetail($withdraw_info['withdraw_no'], $withdraw_info['withdraw_no']);
if ($result['code'] != 0 || (isset($result['data']['detail_status']) && $result['data']['detail_status'] == 'PROCESSING')) {
$error_num = Cache::get('get_transfer_result' . $withdraw_info['withdraw_no']) ?: 0;
if (!$error_num || $error_num < 5) {
(new Cron())->addCron(1, 0, "查询转账结果", "TransferResult", (time() + 60), $withdraw_info['id']);
Cache::set('get_transfer_result' . $withdraw_info['withdraw_no'], ($error_num + 1), 600);
}
return $result;
}
if ($result['data']['detail_status'] == 'FAIL') {
$reason = [
'ACCOUNT_FROZEN' => '账户冻结',
'REAL_NAME_CHECK_FAIL' => '用户未实名',
'NAME_NOT_CORRECT' => '用户姓名校验失败',
'OPENID_INVALID' => 'Openid校验失败',
'TRANSFER_QUOTA_EXCEED' => '超过用户单笔收款额度',
'DAY_RECEIVED_QUOTA_EXCEED' => '超过用户单日收款额度',
'MONTH_RECEIVED_QUOTA_EXCEED' => '超过用户单月收款额度',
'DAY_RECEIVED_COUNT_EXCEED' => '超过用户单日收款次数',
'PRODUCT_AUTH_CHECK_FAIL' => '产品权限校验失败',
'OVERDUE_CLOSE' => '转账关闭',
'ID_CARD_NOT_CORRECT' => '用户身份证校验失败',
'ACCOUNT_NOT_EXIST' => '用户账户不存在',
'TRANSFER_RISK' => '转账存在风险',
'REALNAME_ACCOUNT_RECEIVED_QUOTA_EXCEED' => '用户账户收款受限,请引导用户在微信支付查看详情',
'RECEIVE_ACCOUNT_NOT_PERMMIT' => '未配置该用户为转账收款人',
'PAYER_ACCOUNT_ABNORMAL' => '商户账户付款受限,可前往商户平台-违约记录获取解除功能限制指引',
'PAYEE_ACCOUNT_ABNORMAL' => '用户账户收款异常,请引导用户完善其在微信支付的身份信息以继续收款',
];
$fail_reason = '';
if (isset($result['data']['fail_reason'])) $fail_reason = $reason[$result['data']['fail_reason']] ?? '';
model('member_withdraw')->update(['status' => -2, 'status_name' => '转账失败', 'fail_reason' => $fail_reason], [['id', '=', $withdraw_info['id']]]);
} else if ($result['data']['detail_status'] != 'SUCCESS') {
model('member_withdraw')->update(['status' => -2, 'status_name' => '转账失败', 'fail_reason' => '未获取到转账结果'], [['id', '=', $withdraw_info['id']]]);
}
Cache::delete('get_transfer_result' . $withdraw_info['withdraw_no']);
return $this->success();
}
/**
* Common: 发起请求
* Author: wu-hui
* Time: 2023/06/28 14:22
* @param String $api
* @param array $params
* @param String $type
* @return array
*/
public function requestApi(string $api, array $params=[], string $type = 'post')
{
try {
// 请求使用当前方式 +api_link 不能改变,否则部分接口请求参数会在发送请求时转义 导致请求接口失败
// 根据请求类型执行不同的操作
if ($type == 'post') {
$res = $this->app->chain('{+api_link}')
->post([
'json' => $params,
'api_link' => $api,
'headers' => [
'Wechatpay-Serial' => $this->plateform_certificate_serial
]
]);
} else if ($type == 'patch') {
$res = $this->app->chain('{+api_link}')
->patch([
'json' => $params,
'api_link' => $api,
'headers' => [
'Wechatpay-Serial' => $this->plateform_certificate_serial
]
]);
} else {
$urlParams = http_build_query($params);
$api .= '?' . $urlParams;
$res = $this->app->chain('{+api_link}')
->get(['api_link' => $api]);
}
$result = json_decode($res->getBody()->getContents(), TRUE);
return $this->success($result);
} catch (\Exception $e) {
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$result = json_decode($e->getResponse()->getBody()->getContents(), TRUE);
return $this->error($result, $result['message']);
} else {
return $this->error([], $e->getMessage());
}
}
}
/**
* Common: 微信图片上传
* Author: wu-hui
* Time: 2023/07/21 11:20
* @param $picPath
* @param $api
* @return mixed
* @throws Exception
*/
public function uploadImage($picPath, $api)
{
try {
$media = new MediaUtil($picPath);
$result = $this->app->chain('{+api_link}')
->post([
'api_link' => $api,
'body' => $media->getStream(),
'headers' => [
'Accept' => 'application/json',
'content-type' => $media->getContentType(),
]
]);
return json_decode($result->getBody()->getContents(), TRUE);
} catch (\Exception $e) {
$msg = $e->getMessage();
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$result = json_decode($e->getResponse()->getBody()->getContents(), TRUE);
$msg = $result['message'];
}
throw new Exception($msg);
}
}
}