微信支付小微商戶介面:
其介面操作中需要下載證照針對返回的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
本作品採用《CC 協議》,轉載必須註明作者和本文連結