其接口操作中需要下载证书针对返回的 AEAD_AES_256_GCM算法解密,其中用到了sodium_crypto_aead_aes256gcm_decrypt ( string ciphertext , stringad , string nonce , stringkey )这个函数。使用这个函数需要开启 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
*/
publicmch_id;
/**
* @var string
*/
public sub_mch_id;
/**
* @var string
*/
publicapp_id;
/**
* @var string
*/
public private_key;
/**
* @var string
*/
publicserial_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
*/
privatewechat_app_id;
/**
* WechatCouponService constructor.
*/
public function __construct()
{
this->baseUrl = 'https://api.mch.weixin.qq.com';
// 微信支付 商户号this->mch_id = '';
// 二级商户号,需要走进件系统生成
this->sub_mch_id = '';
// 微信支付 商户号绑定的appidthis->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-haothis->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()
{
staticcharacters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
charactersLength = strlen(characters);
randomString = '';
for (i = 0; i<32;i++) {
randomString .=characters[rand(0, charactersLength - 1)];
}
returnrandomString;
}
/**
* 获取签名
* @param url
* @paramhttp_method
* @param timestamp
* @paramnonce
* @param body
* @parammch_private_key
* @param merchant_id
* @paramserial_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;
}
/**
* 验签
* @parammessage
* @param signature
* @parammerchantPublicKey
* @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
* @paramnonceStr
* @param ciphertext
* @paramaesKey
* @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');
}
/**
* 请求
* @paramurl
* @param array data
* @paramheader
* @param string method
* @param inttime_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;
}
/**
* 获取请求头
* @paramurl
* @param body
* @parammethod
* @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);
}
/**
* @paramkey
* @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 stringstockId 批次号
* @param string openId 用户openid
* @param stringoutRequestNo 商户单据号
* @param int|null couponValue 指定面额发券,面额
* @param int|nullcouponMinimum 指定面额发券,券门槛
* @return array
* @throws Exception
*/
public function couponSend(string stockId, stringopenId, string outRequestNo, ?intcouponValue, ?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 (Exceptione) {
throw new ResourceException(e->getMessage());
}
}
/**
* 条件查询批次列表API
* @param string|nullcreateStartTime 起始时间
* @param string|null createEndTime 终止时间
* @param string|nullstatus 批次状态unactivated:未激活 audit:审核中 running:运行中 stoped:已停止 paused:暂停发放
* @param int offset 分页页码
* @param intlimit 分页大小
* @return array
* @throws Exception
*/
public function queryCouponStocks(?string createStartTime, ?stringcreateEndTime, ?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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询批次详情API
* @param string stockId 批次号
* @return mixed
*/
public function queryCouponStocksInfo(stringstockId)
{
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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询代金券详情API
* @param string openId openid
* @param stringcouponId 代金券id
* @return mixed
*/
public function queryCouponInfo(string openId, stringcouponId)
{
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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询代金券可用商户API
* @param string stockId 批次号
* @param intoffset 分页页码,最大1000。
* @param int limit 分页大小,最大50。
* @return mixed
*/
public function queryCouponMerchants(stringstockId, 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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询代金券可用单品API
* @param string stockId 批次号
* @param intoffset 分页页码,最大1000。
* @param int limit 分页大小,最大50。
* @return mixed
*/
public function queryCouponItems(stringstockId, 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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 根据商户号查用户的券
* @param string openId 用户标识
* @param stringstockId 批次号
* @param string status 状态SENDED:可用 USED:已实扣
* @param stringcreatorMchid 创建批次的商户号
* @param string senderMchid 批次发放商户号
* @param stringavailableMchid 可用商户号
* @param int offset 分页页码
* @param intlimit 分页大小
* @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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 下载批次核销明细API
* @param string stockId 批次号
* @return mixed
*/
public function couponStocksUserFlowDownload(stringstockId)
{
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 (Exceptione) {
throw new ResourceException(e->getMessage());
}
}
/**
* 下载批次退款明细API
* @param stringstockId 批次号
* @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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 设置消息通知地址API
* @param string notifyUrl 支付通知商户url地址。
* @param boolswitch 如果商户不需要再接收营销事件通知,可通过该开关关闭。枚举值:true:开启推送 false:停止推送
* @return mixed
*/
public function settingCouponCallbacks(string notifyUrl, boolswitch)
{
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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
}
?>
<?php
if (!function_exists('getSignContent')) {
/**
* 拼接uri 用于验签等功能
*/
function getSignContent(params) {
ksort(params);
i = 0;stringToBeSigned = "";
foreach (params ask => v) {
if (i == 0) {
stringToBeSigned .= "k" . "=" . "v";
} else {stringToBeSigned .= "&" . "k" . "=" . "v";
}
i++;
}
unset (k, v);
returnstringToBeSigned;
}
}
?>php
<?php
namespace WechatBundleServices;
use CarbonCarbon;
use DingoApiExceptionResourceException;
/**
* Class WechatCouponService
* @package WechatBundleServices
*/
class WechatCouponService
{
/**
* @var string
*/
public baseUrl;
/**
* @var string
*/
publicmch_id;
/**
* @var string
*/
public sub_mch_id;
/**
* @var string
*/
publicapp_id;
/**
* @var string
*/
public private_key;
/**
* @var string
*/
publicserial_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
*/
privatewechat_app_id;
/**
* WechatCouponService constructor.
*/
public function __construct()
{
this->baseUrl = 'https://api.mch.weixin.qq.com';
// 微信支付 商户号this->mch_id = '';
// 二级商户号,需要走进件系统生成
this->sub_mch_id = '';
// 微信支付 商户号绑定的appidthis->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-haothis->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()
{
staticcharacters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
charactersLength = strlen(characters);
randomString = '';
for (i = 0; i<32;i++) {
randomString .=characters[rand(0, charactersLength - 1)];
}
returnrandomString;
}
/**
* 获取签名
* @param url
* @paramhttp_method
* @param timestamp
* @paramnonce
* @param body
* @parammch_private_key
* @param merchant_id
* @paramserial_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;
}
/**
* 验签
* @parammessage
* @param signature
* @parammerchantPublicKey
* @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
* @paramnonceStr
* @param ciphertext
* @paramaesKey
* @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');
}
/**
* 请求
* @paramurl
* @param array data
* @paramheader
* @param string method
* @param inttime_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;
}
/**
* 获取请求头
* @paramurl
* @param body
* @parammethod
* @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);
}
/**
* @paramkey
* @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 stringstockId 批次号
* @param string openId 用户openid
* @param stringoutRequestNo 商户单据号
* @param int|null couponValue 指定面额发券,面额
* @param int|nullcouponMinimum 指定面额发券,券门槛
* @return array
* @throws Exception
*/
public function couponSend(string stockId, stringopenId, string outRequestNo, ?intcouponValue, ?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 (Exceptione) {
throw new ResourceException(e->getMessage());
}
}
/**
* 条件查询批次列表API
* @param string|nullcreateStartTime 起始时间
* @param string|null createEndTime 终止时间
* @param string|nullstatus 批次状态unactivated:未激活 audit:审核中 running:运行中 stoped:已停止 paused:暂停发放
* @param int offset 分页页码
* @param intlimit 分页大小
* @return array
* @throws Exception
*/
public function queryCouponStocks(?string createStartTime, ?stringcreateEndTime, ?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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询批次详情API
* @param string stockId 批次号
* @return mixed
*/
public function queryCouponStocksInfo(stringstockId)
{
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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询代金券详情API
* @param string openId openid
* @param stringcouponId 代金券id
* @return mixed
*/
public function queryCouponInfo(string openId, stringcouponId)
{
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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询代金券可用商户API
* @param string stockId 批次号
* @param intoffset 分页页码,最大1000。
* @param int limit 分页大小,最大50。
* @return mixed
*/
public function queryCouponMerchants(stringstockId, 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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 查询代金券可用单品API
* @param string stockId 批次号
* @param intoffset 分页页码,最大1000。
* @param int limit 分页大小,最大50。
* @return mixed
*/
public function queryCouponItems(stringstockId, 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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 根据商户号查用户的券
* @param string openId 用户标识
* @param stringstockId 批次号
* @param string status 状态SENDED:可用 USED:已实扣
* @param stringcreatorMchid 创建批次的商户号
* @param string senderMchid 批次发放商户号
* @param stringavailableMchid 可用商户号
* @param int offset 分页页码
* @param intlimit 分页大小
* @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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 下载批次核销明细API
* @param string stockId 批次号
* @return mixed
*/
public function couponStocksUserFlowDownload(stringstockId)
{
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 (Exceptione) {
throw new ResourceException(e->getMessage());
}
}
/**
* 下载批次退款明细API
* @param stringstockId 批次号
* @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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
/**
* 设置消息通知地址API
* @param string notifyUrl 支付通知商户url地址。
* @param boolswitch 如果商户不需要再接收营销事件通知,可通过该开关关闭。枚举值:true:开启推送 false:停止推送
* @return mixed
*/
public function settingCouponCallbacks(string notifyUrl, boolswitch)
{
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']);
}
returnresult;
} catch (Exception e) {
throw new ResourceException(e->getMessage());
}
}
}
?>
<?php
if (!function_exists('getSignContent')) {
/**
* 拼接uri 用于验签等功能
*/
function getSignContent(params) {
ksort(params);
i = 0;stringToBeSigned = "";
foreach (params ask => v) {
if (i == 0) {
stringToBeSigned .= "k" . "=" . "v";
} else {stringToBeSigned .= "&" . "k" . "=" . "v";
}
i++;
}
unset (k, v);
returnstringToBeSigned;
}
}
?>php




