<?php
/**
* @description: JSPAI支付 V3
*/
class WeixinPay
{
/**
* @description: 应用ID
* @var string
*/
protected $appid = '';
/**
* @description: 商户号
* @var string
*/
protected $mchid = '';
/**
* @description: 证书私钥路径
* @var string
*/
protected $key_path = 'apiclient_key.pem';
/**
* @description: 证书serial_no,API安全里面点击证书查看
* @var string
*/
protected $serial_no = '';
/**
* @description: v3秘钥
* @var string
*/
protected $v3_key = '';
/**
* @description: 平台证书路径,平台证书需要单独获取,不是商户证书
* @var string
*/
protected $plat_certif_path;
/**
* @description: 平台证书序列号与证书公钥
* @var array
*/
protected $plat_certif = [];
/**
* @description: 日志内容
* @var string
*/
protected $log_data = '';
/**
* @description: 日志记录路径
* @var string
*/
protected $log_path;
public function __construct()
{
$this->log_data = '[ START ] ' . date('Y-m-d H:i:s') . PHP_EOL;
// tp5获取项目根路径
$root_path = str_replace('\\', '/', ROOT_PATH);
$this->key_path = $root_path . 'certif/mch/' . $this->key_path;
$this->plat_certif_path = $root_path . 'certif/plat/';
$this->log_path = $root_path . 'certif/log/' . date('Ym') . '/';
if (!is_dir($this->log_path)) {
mkdir($this->log_path, 0777, true);
}
if (!$this->appid) {
throw new \Exception('APPID不能为空');
}
if (!$this->mchid) {
throw new \Exception('商户号不能为空');
}
if (!$this->serial_no) {
throw new \Exception('商户证书序列号不能为空');
}
if (!$this->v3_key) {
throw new \Exception('V3秘钥不能为空');
}
}
public function __destruct()
{
$this->log_data .= '[ END ]' . PHP_EOL;
$this->_log();
}
/**
* @description: 调起支付,返回前端调用支付的必备参数
* @param string $number 订单号
* @param float $price 支付金额 元
* @param string $user_id 下单人openid
* @return array
*/
public function pay($number, $price, $user_id)
{
$notify = 'https://127.0.0.1:800/notify_pay';
$prepay_id = $this->_order($number, $price, $notify, $user_id);
$data = [
'appid' => $this->appid, // 应用ID
'timeStamp' => time(), // 时间戳
'nonceStr' => $this->_randomString(), // 随机字符串
'package' => 'prepay_id=' . $prepay_id, // 订单详情扩展字符串
'signType' => 'RSA' // 签名方式
];
$data['paySign'] = $this->_sign($data); // 签名
$this->log_data .= '[ PAY ]' . json_encode($data, JSON_UNESCAPED_UNICODE) . PHP_EOL;
return $data;
}
/**
* @description: 支付通知回调
* @param array $param 请求参数
* @param array $header 请求头
* @return array
*/
public function notify($param, $header)
{
$this->log_data .= '[ NOTIFY PARAM ]' . json_encode($param, JSON_UNESCAPED_UNICODE) . PHP_EOL .
'[ NOTIFY HEADER ]' . json_encode($header, JSON_UNESCAPED_UNICODE) . PHP_EOL;
$serial = $header['wechatpay-serial']; //证书序列号
$public_key = $this->_verify_plat_serial($serial);
if (!$public_key) {
return ['code' => 'ERROR', 'message' => '请求错误'];
}
if (!$this->_verify_plat_sign($header['wechatpay-timestamp'], $header['wechatpay-nonce'], json_encode($param, JSON_UNESCAPED_UNICODE), $header['wechatpay-signature'], $public_key)) {
return ['code' => 'ERROR', 'message' => '签名错误'];
}
$data = $param['resource'];
$info = $this->_decryptToString($data['ciphertext'], $this->v3_key, $data['nonce'], $data['associated_data']);
$this->log_data .= '[ NOTIFY DATA ]' . $info . PHP_EOL;
$info = json_decode($info, true);
if (!$info) {
return ['code' => 'ERROR', 'message' => '解密失败'];
}
if ($info['appid'] != $this->appid || $info['mchid'] != $this->mchid) {
return ['code' => 'ERROR', 'message' => '商户信息错误'];
}
$order = '';
if (!$order) {
return ['code' => 'ERROR', 'message' => '订单不存在'];
}
if ($order['state'] == 3) {
return ['code' => 'SUCCESS', 'message' => '已支付'];
}
$data = [
'order_header_id' => $order['id'],
'out_trade_no' => $info['out_trade_no'], // 商户订单号,自定义的订单号
'transaction_id' => $info['transaction_id'], // 微信支付订单号,微信的订单号
'trade_type' => $info['trade_type'], // 交易类型
'trade_state' => $info['trade_state'], // 交易状态
'bank_type' => $info['bank_type'], // 付款银行
'success_time' => $info['success_time'], // 支付完成时间
'trade_state_desc' => $info['trade_state_desc'], // 交易状态描述
'create_time' => time(),
];
if ($info['trade_state'] == 'SUCCESS') {
return ['code' => 'SUCCESS', 'message' => '支付成功'];
} else {
return ['code' => 'ERROR', 'message' => '支付失败'];
}
}
/**
* @description: 下单获取预支付id
* @param string $number 订单号
* @param float $price 支付金额 元
* @param string $notify 回调通知地址,https
* @param string $user_id 下单人openid
* @return string
*/
private function _order($number, $price, $notify, $user_id)
{
$data = [
'appid' => $this->appid, // 应用ID
'mchid' => $this->mchid, // 直连商户号
'description' => '参会会议费', // 商品描述
'out_trade_no' => $number, // 商户订单号
'notify_url' => $notify, // 通知地址,https://
'amount' => ['total' => (int)bcmul($price, 100)], // 订单金额
'payer' => ['openid' => $user_id], // 支付者
];
$param = json_encode($data);
$authorization = $this->_get_sign('post', '/v3/pay/transactions/jsapi', $param);
$header = [
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Content-Length: ' . strlen($param),
$authorization
],
CURLOPT_USERAGENT => 'https://zh.wikipedia.org/wiki/User_agent'
];
// 本人自定义的curl类
$curl = new Curl('https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi', 60, 'post', $param, $header);
$result = $curl->curl_func();
$this->log_data .= '[ ORDER ]' . json_encode($data, JSON_UNESCAPED_UNICODE) . PHP_EOL .
'[ ORDER RESULT ]' . $result . PHP_EOL;
return json_decode($result, true)['prepay_id'];
}
/**
* @description: 数据签名
* @param array $data 需要签名的数据
* @return string
*/
private function _sign($data)
{
$str = $data['appid'] . "\n" . $data['timeStamp'] . "\n" . $data['nonceStr'] . "\n" . $data['package']. "\n";
$key = $this->_private_key();
openssl_sign($str, $result, $key, 'sha256WithRSAEncryption');
openssl_free_key($key);
return base64_encode($result);
}
/**
* @description: 获取商户私钥对象
* @param {*}
* @return resource
*/
private function _private_key()
{
return openssl_get_privatekey(file_get_contents($this->key_path));
}
/**
* 解密数据(证书/回调报文)
*
* @param string $ciphertext - base64编码的密文。
* @param string $key - 密钥,32字节字符串。
* @param string $nonceStr - 随机字符串。
* @param string $associatedData - 附加的验证数据,可能空字符串。
*
* @return string - The utf-8 plaintext.
*/
private function _decryptToString($ciphertext, $key, $nonceStr, $associatedData)
{
$ciphertext = base64_decode($ciphertext);
$ctext = substr($ciphertext, 0, -16);
$authTag = substr($ciphertext, -16);
return openssl_decrypt($ctext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonceStr, $authTag, $associatedData);
}
/**
* @description: 验证平台证书序列号
* @param string $serial 平台证书序列号
* @return string|false 证书公钥或false(序列号不匹配)
*/
private function _verify_plat_serial($serial)
{
$i = 2;
while ($i-- > 0) {
$this->_get_local_plat_certif();
if (!$this->plat_certif) {
$this->_get_plat_certif();
$this->_get_local_plat_certif();
}
if (array_key_exists($serial, $this->plat_certif)) {
return $this->plat_certif[$serial];
} elseif ($i > 0) {
$this->_get_plat_certif();
}
}
return false;
}
/**
* @description: 获取下载到本地的平台证书信息
* @param {*}
* @return void
*/
private function _get_local_plat_certif()
{
$plat_certif = [];
$dir = opendir($this->plat_certif_path);
while ($file = readdir($dir)) {
if ($file != '.' && $file != '..') {
$serial = explode('_', $file)[1];
$serial = explode('.', $serial)[0];
$plat_certif[$serial] = file_get_contents($this->plat_certif_path . $file);
}
}
$this->plat_certif = $plat_certif;
}
/**
* @description: 获取平台证书/序列号保存在本地
* @param {*}
* @return void
*/
private function _get_plat_certif()
{
$authorization = $this->_get_sign('get', '/v3/certificates');
$header = [
CURLOPT_HTTPHEADER => [
$authorization,
'Accept: application/json',
],
CURLOPT_USERAGENT => 'https://zh.wikipedia.org/wiki/User_agent'
];
// 获取平台证书
$curl = new Curl('https://api.mch.weixin.qq.com/v3/certificates', 60, 'get', [], $header);
$results = $curl->curl_func();
$this->log_data .= '[ PLAT DATA ]' . $results . PHP_EOL;
$results = json_decode($results, true)['data'];
foreach ($results as $result) {
$info = $this->_decryptToString($result['encrypt_certificate']['ciphertext'],
$this->v3_key,
$result['encrypt_certificate']['nonce'],
$result['encrypt_certificate']['associated_data']
);
file_put_contents($this->plat_certif_path . 'wechatpay_' . $result['serial_no'] . '.pem', $info); // serial_no为序列号,pem为证书,证书内容时平台证书公钥
}
}
/**
* @description: 验证回调签名
* @param int $timestamp 应答时间戳
* @param string $nonce 应答随机串
* @param string $data 应答主体
* @param string $signature 应答签名数据
* @param string $public_key
* @return bool
*/
private function _verify_plat_sign($timestamp, $nonce, $data, $signature, $public_key)
{
$ydata = $timestamp . "\n" . $nonce . "\n" . str_replace('\\', '', $data) . "\n";
$signature = base64_decode($signature);
$verify = openssl_verify($ydata, $signature, $public_key, 'sha256WithRSAEncryption');
return (bool)$verify;
}
/**
* @description: 生成请求头签名
* @param string $method 请求方法
* @param string $url 请求地址
* @param string $data 请求体
* @return string
*/
private function _get_sign($method, $url, $data = '')
{
$timestamp = time();
$nonce = $this->_randomString();
$sign = strtoupper($method) . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $data . "\n";
openssl_sign($sign, $raw_sign, $this->_private_key(), 'sha256WithRSAEncryption');
$raw_sign = base64_encode($raw_sign);
$token = sprintf('Authorization: WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
$this->mchid, $nonce, $timestamp, $this->serial_no, $raw_sign);
return $token;
}
/**
* @description: 获取随机数
* @param int $len 长度
* @return string
*/
private function _randomString($len = 32)
{
$string = '';
$char = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
for ($i = 0; $i < $len; $i++) {
$string .= $char[mt_rand(0, strlen($char) - 1)];
}
return $string;
}
/**
* @description: 日志记录
* @return void
*/
private function _log()
{
error_log($this->log_data, 3, $this->_get_log_file());
}
/**
* @description: 获取日志文件名,每天一个日志文件存放在当前年月目录下,当日志文件大小超过2M时,生成新日志文件
* @param {*}
* @return string
*/
private function _get_log_file()
{
$file = $this->log_path . date('d') . '.log';
if (is_file($file) && filesize($file) > 2097152) {
rename($file, $this->log_path . date('Ymd') . '_' . uniqid(date('His') . '_', true) . '.log');
}
return $file;
}
}