其接口操作中需要下载证书针对返回的 AEAD_AES_256_GCM算法解密,其中用到了sodium_crypto_aead_aes256gcm_decrypt ( string ciphertext , string ad , string nonce , string key )这个函数。使用这个函数需要开启 libsodium 扩展。
官方文档对该扩展的说明如下: As of PHP 7.2.0 this extension is bundled with PHP. For older PHP versions this extension is available via PECL. 从php 7.2.0开始,这个扩展与php捆绑在一起。对于旧的PHP版本,此扩展可通过pecl获得。
<?php
namespace WechatBundleServices;
use CarbonCarbon;
use DingoApiExceptionResourceException;
/**
* Class WechatCouponService
* @package WechatBundleServices
*/
class WechatCouponService
{
/**
* @var string
*/
public $baseUrl;
/**
* @var string
*/
public $mch_id;
/**
* @var string
*/
public $sub_mch_id;
/**
* @var string
*/
public $app_id;
/**
* @var string
*/
public $private_key;
/**
* @var string
*/
public $serial_no;
/**
* @var string
*/
public $mch_key;
const REDIS_NAME_WECHAT_PAY_CERT = 'wechat_pay_v3_cert_no';
const KEY_LENGTH_BYTE = 32;
const AUTH_TAG_LENGTH_BYTE = 16;
const GET_CERTIFICATES = '/v3/certificates';//获取商户平台证书
const CREATE_COUPON_STOCKS = '/v3/marketing/favor/coupon-stocks';//创建代金券批次API
const START_COUPON_STOCKS = '/v3/marketing/favor/stocks/%d/start';//激活代金券批次API
const COUPON_SEND = '/v3/marketing/favor/users/%s/coupons';//发放代金券API
const PAUSE_COUPON_STOCKS = '/v3/marketing/favor/stocks/%d/pause';//暂停代金券批次API
const RESTART_COUPON_STOCKS = '/v3/marketing/favor/stocks/%d/pause';//重启代金券批次API
const QUERY_COUPON_STOCKS = '/v3/marketing/favor/stocks';//条件查询批次列表API
const QUERY_COUPON_STOCKS_INFO = '/v3/marketing/favor/stocks/%s';//查询批次详情API
const QUERY_COUPON_INFO = '/v3/marketing/favor/users/%s/coupons/%s';//查询代金券详情API
const QUERY_COUPON_MERCHANTS = '/v3/marketing/favor/stocks/%s/merchants';//查询代金券可用商户API
const QUERY_COUPON_ITEMS = '/v3/marketing/favor/stocks/%s/items';//查询代金券可用单品API
const QUERY_USER_COUPON = '/v3/marketing/favor/users/%s/coupons';//根据商户号查用户的券
const COUPON_STOCKS_USER_FLOW_DOWNLOAD = '/v3/marketing/favor/stocks/%s/use-flow';//下载批次核销明细API
const COUPON_STOCKS_REFUND_FLOW_DOWNLOAD = '/v3/marketing/favor/stocks/%s/refund-flow';//下载批次退款明细API
const SETTING_COUPON_CALLBACKS = '/v3/marketing/favor/callbacks';//设置消息通知地址API
/**
* @var string
*/
private $wechat_app_id;
/**
* WechatCouponService constructor.
*/
public function __construct()
{
$this->baseUrl = 'https://api.mch.weixin.qq.com';
// 微信支付 商户号
$this->mch_id = '';
// 二级商户号,需要走进件系统生成
$this->sub_mch_id = '';
// 微信支付 商户号绑定的appid
$this->app_id = '';
// 商户私钥
$this->private_key = wordwrap(file_get_contents(storage_path('apiclient_key.pem')), 64, "n", true);
// 商户证书序列号
// 如何查看证书序列号:https://wechatpay-api.gitbook.io/wechatpay-api-v3/chang-jian-wen-ti/zheng-shu-xiang-guan#ru-he-cha-kan-zheng-shu-xu-lie-hao
$this->serial_no = '';
// apiv3秘钥:https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/api-v3-mi-yao
$this->mch_key = '';
}
/**
* 获取API v3证书
* @return mixed
*/
public function getCert()
{
$wechatPayV3CertNo = app('redis')->get(self::REDIS_NAME_WECHAT_PAY_CERT);
if (empty($wechatPayV3CertNo)) {
try {
$url = $this->baseUrl . self::GET_CERTIFICATES;
$timestamp = time();
$nonce = $this->nonce_str();
$body = '';
$sign = $this->sign($url, 'GET', $timestamp, $nonce, $body, $this->getPrivateKey($this->private_key), $this->mch_id,
$this->serial_no);
$header = [
'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $sign,
'Accept:application/json',
'User-Agent:' . $this->mch_id,
];
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!isset($result['data'])) {
throw new Exception('微信支付商户平台小微企业请求证书请求失败' . json_encode($result, 256));
}
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
app('api.exception')->report($e->getMessage());
}
$wechatPayV3CertNo = $result['data']['0']['serial_no'];
app('redis')->set(self::REDIS_NAME_WECHAT_PAY_CERT, $wechatPayV3CertNo, 'EX', 600);
}
return $wechatPayV3CertNo;
}
/**
* 获取随机字符串
* @return string
*/
protected function nonce_str()
{
static $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < 32; $i++) {
$randomString .= $characters[rand(0, $charactersLength - 1)];
}
return $randomString;
}
/**
* 获取签名
* @param $url
* @param $http_method
* @param $timestamp
* @param $nonce
* @param $body
* @param $mch_private_key
* @param $merchant_id
* @param $serial_no
* @return string
*/
protected function sign($url, $http_method, $timestamp, $nonce, $body, $mch_private_key, $merchant_id, $serial_no)
{
$url_parts = parse_url($url);
$canonical_url = ($url_parts['path'] . (!empty($url_parts['query']) ? "?${url_parts['query']}" : ""));
$message = $http_method . "n" .
$canonical_url . "n" .
$timestamp . "n" .
$nonce . "n" .
$body . "n";
openssl_sign($message, $raw_sign, $mch_private_key, 'sha256WithRSAEncryption');
$sign = base64_encode($raw_sign);
$schema = 'WECHATPAY2-SHA256-RSA2048';
$token = sprintf('mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
$merchant_id, $nonce, $timestamp, $serial_no, $sign);
return $token;
}
/**
* 验签
* @param $message
* @param $signature
* @param $merchantPublicKey
* @return bool|int
*/
private function verify($message, $signature, $merchantPublicKey)
{
if (empty($merchantPublicKey)) {
return false;
}
if (!in_array('sha256WithRSAEncryption', openssl_get_md_methods(true))) {
throw new RuntimeException("当前PHP环境不支持SHA256withRSA");
}
$signature = base64_decode($signature);
return openssl_verify($message, $signature, $this->getPublicKey($merchantPublicKey), 'sha256WithRSAEncryption');
}
/**
* @param $associatedData
* @param $nonceStr
* @param $ciphertext
* @param $aesKey
* @return bool|string
*/
private function decryptToString($associatedData, $nonceStr, $ciphertext, $aesKey = '')
{
if (empty($aesKey)) {
$aesKey = $this->mch_key;
}
$ciphertext = base64_decode($ciphertext);
if (strlen($ciphertext) <= self::AUTH_TAG_LENGTH_BYTE) {
return false;
}
// ext-sodium (default installed on >= PHP 7.2)
if (function_exists('sodium_crypto_aead_aes256gcm_is_available') &&
sodium_crypto_aead_aes256gcm_is_available()) {
return sodium_crypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $aesKey);
}
// ext-libsodium (need install libsodium-php 1.x via pecl)
if (function_exists('Sodiumcrypto_aead_aes256gcm_is_available') &&
Sodiumcrypto_aead_aes256gcm_is_available()) {
return Sodiumcrypto_aead_aes256gcm_decrypt($ciphertext, $associatedData, $nonceStr, $aesKey);
}
// openssl (PHP >= 7.1 support AEAD)
if (PHP_VERSION_ID >= 70100 && in_array('aes-256-gcm', openssl_get_cipher_methods())) {
$ctext = substr($ciphertext, 0, -self::AUTH_TAG_LENGTH_BYTE);
$authTag = substr($ciphertext, -self::AUTH_TAG_LENGTH_BYTE);
return openssl_decrypt($ctext, 'aes-256-gcm', $aesKey, OPENSSL_RAW_DATA, $nonceStr,
$authTag, $associatedData);
}
throw new RuntimeException('AEAD_AES_256_GCM需要PHP 7.1以上或者安装libsodium-php');
}
/**
* 请求
* @param $url
* @param array $data
* @param $header
* @param string $method
* @param int $time_out
* @return mixed
*/
private function curl($url, $data = [], $header, $method = 'POST', $time_out = 3)
{
$curl = curl_init();
// 设置curl允许执行的最长秒数
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
curl_setopt($curl, CURLOPT_HEADER, false);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_TIMEOUT, $time_out);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
if ($method == 'POST') {
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
}
// 执行操作
$result = curl_exec($curl);
curl_close($curl);
return $result;
}
/**
* 获取请求头
* @param $url
* @param $body
* @param $method
* @return array
*/
protected function getCurlHeader($url, $body, $method)
{
$timestamp = time();
$nonce = $this->nonce_str();
$sign = $this->sign($url, $method, $timestamp, $nonce, $body, $this->getPrivateKey($this->private_key), $this->mch_id,
$this->serial_no);
return [
'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $sign,
'Accept:application/json',
'User-Agent:' . $this->mch_id,
'Content-Type:application/json',
'Wechatpay-Serial:' . $this->getCert(),
];
}
/**
* 获取私钥
* @param $key
* @return bool|resource
*/
protected function getPrivateKey($key)
{
return openssl_get_privatekey($key);
}
/**
* @param $key
* @return resource
*/
protected function getPublicKey($key)
{
return openssl_get_publickey($key);
}
/**
* 获取请求头
* @return array
*/
private function getHeaders()
{
$headers = array();
foreach ($_SERVER as $key => $value) {
if ('HTTP_' == substr($key, 0, 5)) {
$headers[str_replace('_', '-', substr($key, 5))] = $value;
}
if (isset($_SERVER['PHP_AUTH_DIGEST'])) {
$header['AUTHORIZATION'] = $_SERVER['PHP_AUTH_DIGEST'];
} elseif (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
$header['AUTHORIZATION'] = base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . $_SERVER['PHP_AUTH_PW']);
}
if (isset($_SERVER['CONTENT_LENGTH'])) {
$header['CONTENT-LENGTH'] = $_SERVER['CONTENT_LENGTH'];
}
if (isset($_SERVER['CONTENT_TYPE'])) {
$header['CONTENT-TYPE'] = $_SERVER['CONTENT_TYPE'];
}
}
return $headers;
}
/**
* 发放代金券API
* @param string $stockId 批次号
* @param string $openId 用户openid
* @param string $outRequestNo 商户单据号
* @param int|null $couponValue 指定面额发券,面额
* @param int|null $couponMinimum 指定面额发券,券门槛
* @return array
* @throws Exception
*/
public function couponSend(string $stockId, string $openId, string $outRequestNo, ?int $couponValue, ?int $couponMinimum): array
{
try {
$requestData = [
'stock_id' => $stockId,
'out_request_no' => $outRequestNo,
'appid' => $this->app_id,
'stock_creator_mchid' => $this->mch_id,
];
if (!empty($couponValue)) {
$requestData['coupon_value'] = $couponValue;
}
if (!empty($couponMinimum)) {
$requestData['coupon_minimum'] = $couponMinimum;
}
$header = $this->getCurlHeader($this->baseUrl . sprintf(self::COUPON_SEND, $openId), json_encode($requestData), 'POST');
$result = $this->curl($this->baseUrl . sprintf(self::COUPON_SEND, $openId), json_encode($requestData), $header, 'POST');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 条件查询批次列表API
* @param string|null $createStartTime 起始时间
* @param string|null $createEndTime 终止时间
* @param string|null $status 批次状态unactivated:未激活 audit:审核中 running:运行中 stoped:已停止 paused:暂停发放
* @param int $offset 分页页码
* @param int $limit 分页大小
* @return array
* @throws Exception
*/
public function queryCouponStocks(?string $createStartTime, ?string $createEndTime, ?string $status, $offset = 0, $limit = 10): ?array
{
try {
if (!empty($status) && !in_array($status, ['unactivated', 'audit', 'running', 'stoped', 'paused'])) {
throw new Exception('状态错误');
}
$requestData = [
'stock_creator_mchid' => $this->mch_id,
'offset' => $offset,
'limit' => $limit
];
if (!empty($status)) {
$requestData['status'] = $status;
}
if (!empty($createStartTime)) {
$requestData['create_start_time'] = Carbon::createFromTimestamp(strtotime($createStartTime))->toRfc3339String();
}
if (!empty($createEndTime)) {
$requestData['create_end_time'] = Carbon::createFromTimestamp(strtotime($createEndTime))->toRfc3339String();
}
$url = $this->baseUrl . self::QUERY_COUPON_STOCKS . '?' . getSignContent($requestData);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 查询批次详情API
* @param string $stockId 批次号
* @return mixed
*/
public function queryCouponStocksInfo(string $stockId)
{
try {
if (empty($stockId)) {
throw new Exception('批次号不能为空');
}
$requestData = [
'stock_creator_mchid' => $this->mch_id
];
$url = $this->baseUrl . sprintf(self::QUERY_COUPON_STOCKS_INFO, $stockId) . '?' . getSignContent($requestData);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 查询代金券详情API
* @param string $openId openid
* @param string $couponId 代金券id
* @return mixed
*/
public function queryCouponInfo(string $openId, string $couponId)
{
try {
if (empty($openId)) {
throw new Exception('openId不能为空');
}
if (empty($couponId)) {
throw new Exception('优惠券id不能为空');
}
$requestData = [
'appid' => $this->app_id
];
$url = $this->baseUrl . sprintf(self::QUERY_COUPON_INFO, $openId, $couponId) . '?' . getSignContent($requestData);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 查询代金券可用商户API
* @param string $stockId 批次号
* @param int $offset 分页页码,最大1000。
* @param int $limit 分页大小,最大50。
* @return mixed
*/
public function queryCouponMerchants(string $stockId, $offset = 1, $limit = 10)
{
try {
if (empty($stockId)) {
throw new Exception('批次号不能为空');
}
$requestData = [
'stock_creator_mchid' => $this->mch_id,
'offset' => $offset,
'limit' => $limit
];
$url = $this->baseUrl . sprintf(self::QUERY_COUPON_MERCHANTS, $stockId) . '?' . getSignContent($requestData);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 查询代金券可用单品API
* @param string $stockId 批次号
* @param int $offset 分页页码,最大1000。
* @param int $limit 分页大小,最大50。
* @return mixed
*/
public function queryCouponItems(string $stockId, $offset = 1, $limit = 10)
{
try {
if (empty($stockId)) {
throw new Exception('批次号不能为空');
}
$requestData = [
'stock_creator_mchid' => $this->mch_id,
'offset' => $offset,
'limit' => $limit
];
$url = $this->baseUrl . sprintf(self::QUERY_COUPON_ITEMS, $stockId) . '?' . getSignContent($requestData);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 根据商户号查用户的券
* @param string $openId 用户标识
* @param string $stockId 批次号
* @param string $status 状态SENDED:可用 USED:已实扣
* @param string $creatorMchid 创建批次的商户号
* @param string $senderMchid 批次发放商户号
* @param string $availableMchid 可用商户号
* @param int $offset 分页页码
* @param int $limit 分页大小
* @return mixed
*/
public function queryUserCoupon(string $openId, $stockId = '', $status = '', $creatorMchid = '', $senderMchid = '', $availableMchid = '', $offset = 0, $limit = 20)
{
try {
if (!empty($status) && !in_array($status, ['SENDED', 'USED'])) {
throw new Exception('状态错误');
}
$requestData = [
'appid' => $this->app_id,
'offset' => $offset,
'limit' => $limit,
'creator_mchid' => $this->mch_id,
];
if (!empty($stockId)) {
$requestData['stock_id'] = $stockId;
}
if (!empty($status)) {
$requestData['status'] = $status;
}
if (!empty($senderMchid)) {
$requestData['available_mchid'] = $senderMchid;
}
if (!empty($availableMchid)) {
$requestData['available_mchid'] = $availableMchid;
}
$url = $this->baseUrl . sprintf(self::QUERY_USER_COUPON, $openId) . '?' . getSignContent($requestData);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 下载批次核销明细API
* @param string $stockId 批次号
* @return mixed
*/
public function couponStocksUserFlowDownload(string $stockId)
{
try {
if (empty($stockId)) {
throw new Exception('批次号不能为空');
}
$url = $this->baseUrl . sprintf(self::COUPON_STOCKS_USER_FLOW_DOWNLOAD, $stockId);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 下载批次退款明细API
* @param string $stockId 批次号
* @return mixed
*/
public function couponStocksRefundFlowDownload(string $stockId)
{
try {
if (empty($stockId)) {
throw new Exception('批次号不能为空');
}
$url = $this->baseUrl . sprintf(self::COUPON_STOCKS_REFUND_FLOW_DOWNLOAD, $stockId);
$header = $this->getCurlHeader($url, '', 'GET');
$result = $this->curl($url, '', $header, 'GET');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
/**
* 设置消息通知地址API
* @param string $notifyUrl 支付通知商户url地址。
* @param bool $switch 如果商户不需要再接收营销事件通知,可通过该开关关闭。枚举值:true:开启推送 false:停止推送
* @return mixed
*/
public function settingCouponCallbacks(string $notifyUrl, bool $switch)
{
try {
if (empty($notifyUrl)) {
throw new ResourceException('回调地址不能为空,且必须是完整的https链接');
}
$requestData = [
'mchid' => $this->mch_id,
'notify_url' => $notifyUrl,
'switch' => $switch
];
$url = $this->baseUrl . self::SETTING_COUPON_CALLBACKS;
$header = $this->getCurlHeader($url, json_encode($requestData), 'POST');
$result = $this->curl($url, json_encode($requestData), $header, 'POST');
$result = json_decode($result, true);
if (!$result || isset($result['code'])) {
if (!$result) {
throw new ResourceException('操作失败,请刷新页面后重试');
}
throw new ResourceException($result['code'] . '---' . $result['message']);
}
return $result;
} catch (Exception $e) {
throw new ResourceException($e->getMessage());
}
}
}
?>
<?php
if (!function_exists('getSignContent')) {
/**
* 拼接uri 用于验签等功能
*/
function getSignContent($params) {
ksort($params);
$i = 0;
$stringToBeSigned = "";
foreach ($params as $k => $v) {
if ($i == 0) {
$stringToBeSigned .= "$k" . "=" . "$v";
} else {
$stringToBeSigned .= "&" . "$k" . "=" . "$v";
}
$i++;
}
unset ($k, $v);
return $stringToBeSigned;
}
}
?>php
PHP
Copy