eee
This commit is contained in:
+7
-1
@@ -6,6 +6,12 @@ class Wx
|
|||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
|
public string $offerId;
|
||||||
|
|
||||||
|
|
||||||
|
public string $appKey;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
@@ -53,4 +59,4 @@ class Wx
|
|||||||
*/
|
*/
|
||||||
public string $SerialNumber;
|
public string $SerialNumber;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,35 @@
|
|||||||
namespace wchat\wx;
|
namespace wchat\wx;
|
||||||
|
|
||||||
|
|
||||||
|
use Exception;
|
||||||
use wchat\common\Multiprogramming;
|
use wchat\common\Multiprogramming;
|
||||||
|
use wchat\wx\V3\Libs\WxMsgCrypt;
|
||||||
|
use wchat\wx\V3\Libs\XPayGoodsDeliverNotify;
|
||||||
|
|
||||||
class SmallProgram extends Multiprogramming
|
class SmallProgram extends Multiprogramming
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $encrypt
|
||||||
|
* @param string $signature
|
||||||
|
* @param string $timestamp
|
||||||
|
* @param string $nonce
|
||||||
|
* @param string $msg_signature
|
||||||
|
* @return array|bool
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function decode(string $encrypt, string $signature, string $timestamp, string $nonce, string $msg_signature): XPayGoodsDeliverNotify|bool
|
||||||
|
{
|
||||||
|
$WxMsgCrypt = new WxMsgCrypt($this->payConfig->notice['token'], $this->payConfig->notice['secret'], $this->payConfig->appId);
|
||||||
|
if (!$WxMsgCrypt->verifySignature($timestamp, $nonce, $encrypt, $msg_signature)) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return XPayGoodsDeliverNotify::fromJson($WxMsgCrypt->decryptMsg($encrypt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace wchat\wx\V3\Libs;
|
||||||
|
|
||||||
|
class GoodsInfo
|
||||||
|
{
|
||||||
|
public string $ProductId;
|
||||||
|
public int $Quantity;
|
||||||
|
public int $OrigPrice;
|
||||||
|
public int $ActualPrice;
|
||||||
|
public string $Attach;
|
||||||
|
|
||||||
|
public function __construct(array $data)
|
||||||
|
{
|
||||||
|
$this->ProductId = $data['ProductId'];
|
||||||
|
$this->Quantity = $data['Quantity'];
|
||||||
|
$this->OrigPrice = $data['OrigPrice'];
|
||||||
|
$this->ActualPrice = $data['ActualPrice'];
|
||||||
|
$this->Attach = $data['Attach'];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace wchat\wx\V3\Libs;
|
||||||
|
|
||||||
|
class WeChatPayInfo
|
||||||
|
{
|
||||||
|
public string $MchOrderNo;
|
||||||
|
public string $TransactionId;
|
||||||
|
public int $PaidTime;
|
||||||
|
|
||||||
|
public function __construct(array $data)
|
||||||
|
{
|
||||||
|
$this->MchOrderNo = $data['MchOrderNo'];
|
||||||
|
$this->TransactionId = $data['TransactionId'];
|
||||||
|
$this->PaidTime = $data['PaidTime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace wchat\wx\V3\Libs;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信公众号消息加解密类
|
||||||
|
* 基于微信官方文档实现:https://developers.weixin.qq.com/doc/oplatform/developers/dev/push/encryption.html
|
||||||
|
* 适用场景:安全模式(推荐)下的消息接收与回复
|
||||||
|
*/
|
||||||
|
class WxMsgCrypt
|
||||||
|
{
|
||||||
|
private string $token; // 开发者设置的Token
|
||||||
|
private string $encodingAesKey; // 消息加解密密钥(43位字符)
|
||||||
|
private string $appid; // 公众号AppId
|
||||||
|
private string|false $aesKey; // 解码后的AES密钥(32字节)
|
||||||
|
private string $iv; // 初始向量(AES密钥的前16字节)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param string $token 开发者设置的Token
|
||||||
|
* @param string $encodingAesKey 公众号后台的EncodingAESKey(43位)
|
||||||
|
* @param string $appid 公众号AppId
|
||||||
|
* @throws Exception 当EncodingAESKey格式不正确时抛出异常
|
||||||
|
*/
|
||||||
|
public function __construct(string $token, string $encodingAesKey, string $appid)
|
||||||
|
{
|
||||||
|
$this->token = $token;
|
||||||
|
$this->encodingAesKey = $encodingAesKey;
|
||||||
|
$this->appid = $appid;
|
||||||
|
|
||||||
|
// 解码EncodingAESKey:官方文档要求尾部补一个"="后做Base64解码
|
||||||
|
$key = $encodingAesKey . '=';
|
||||||
|
$this->aesKey = base64_decode($key);
|
||||||
|
if (strlen($this->aesKey) != 32) {
|
||||||
|
throw new Exception('EncodingAESKey 解码后长度必须为32字节,当前长度为:' . strlen($this->aesKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES CBC模式的IV取密钥的前16字节(官方示例代码标准做法)
|
||||||
|
$this->iv = substr($this->aesKey, 0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证消息签名(用于接收消息时)
|
||||||
|
* @param string $timestamp URL中的timestamp参数
|
||||||
|
* @param string $nonce URL中的nonce参数
|
||||||
|
* @param string $encrypt 消息体中的Encrypt字段(密文)
|
||||||
|
* @param string $msgSignature URL中的msg_signature参数
|
||||||
|
* @return bool 签名验证结果
|
||||||
|
*/
|
||||||
|
public function verifySignature(string $timestamp, string $nonce, string $encrypt, string $msgSignature): bool
|
||||||
|
{
|
||||||
|
$signature = $this->generateSignature($timestamp, $nonce, $encrypt);
|
||||||
|
return $signature === $msgSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密消息(安全模式接收消息时调用)
|
||||||
|
* @param string $encrypt 消息体中的Encrypt字段(Base64密文)
|
||||||
|
* @return string 解密后的明文消息(XML或JSON格式)
|
||||||
|
* @throws Exception 当解密失败或AppId不匹配时抛出异常
|
||||||
|
*/
|
||||||
|
public function decryptMsg(string $encrypt): string
|
||||||
|
{
|
||||||
|
// 1. Base64解码
|
||||||
|
$encryptData = base64_decode($encrypt);
|
||||||
|
if ($encryptData === false) {
|
||||||
|
throw new Exception('Base64解码失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. AES解密(CBC模式,PKCS#7填充)
|
||||||
|
$decrypted = openssl_decrypt($encryptData, 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $this->iv);
|
||||||
|
if ($decrypted === false) {
|
||||||
|
throw new Exception('AES解密失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 去除PKCS#7填充
|
||||||
|
$result = $this->pkcs7Unpad($decrypted);
|
||||||
|
|
||||||
|
// 4. 解析明文结构:16字节随机字符串 + 4字节网络字节序长度 + 消息内容 + AppId
|
||||||
|
if (strlen($result) < 20) { // 至少需要16+4=20字节
|
||||||
|
throw new Exception('解密后的数据长度异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
$random = substr($result, 0, 16); // 16字节随机字符串(可忽略)
|
||||||
|
$msgLen = unpack('N', substr($result, 16, 4))[1]; // 4字节网络字节序长度
|
||||||
|
$msg = substr($result, 20, $msgLen); // 消息内容
|
||||||
|
$appid = substr($result, 20 + $msgLen); // 剩余的AppId
|
||||||
|
|
||||||
|
// 5. 验证AppId是否匹配
|
||||||
|
if ($appid !== $this->appid) {
|
||||||
|
throw new Exception('AppId不匹配,可能被篡改');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密消息(安全模式回复消息时调用)
|
||||||
|
* @param string $replyMsg 待回复的明文消息(XML或JSON格式)
|
||||||
|
* @param int|null $timestamp 时间戳(可空,为空则自动生成)
|
||||||
|
* @param string|null $nonce 随机字符串(可空,为空则自动生成)
|
||||||
|
* @return array 包含Encrypt, MsgSignature, TimeStamp, Nonce的关联数组
|
||||||
|
* @throws Exception 当加密失败时抛出异常
|
||||||
|
*/
|
||||||
|
public function encryptMsg(string $replyMsg, ?int $timestamp = null, ?string $nonce = null): array
|
||||||
|
{
|
||||||
|
// 1. 生成16字节随机字符串
|
||||||
|
$random = openssl_random_pseudo_bytes(16);
|
||||||
|
|
||||||
|
// 2. 计算消息长度(网络字节序)
|
||||||
|
$msgLen = pack('N', strlen($replyMsg));
|
||||||
|
|
||||||
|
// 3. 构造待加密的字符串:随机字符串(16B) + 消息长度(4B) + 消息内容 + AppId
|
||||||
|
$plainText = $random . $msgLen . $replyMsg . $this->appid;
|
||||||
|
|
||||||
|
// 4. PKCS#7填充(按16字节块)
|
||||||
|
$padded = $this->pkcs7Pad($plainText);
|
||||||
|
|
||||||
|
// 5. AES加密
|
||||||
|
$encrypted = openssl_encrypt($padded, 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $this->iv);
|
||||||
|
if ($encrypted === false) {
|
||||||
|
throw new Exception('AES加密失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Base64编码得到Encrypt字段
|
||||||
|
$encrypt = base64_encode($encrypted);
|
||||||
|
|
||||||
|
// 7. 生成时间戳和随机数
|
||||||
|
$timestamp = $timestamp ?: time();
|
||||||
|
$nonce = $nonce ?: $this->generateNonce();
|
||||||
|
|
||||||
|
// 8. 生成消息签名
|
||||||
|
$msgSignature = $this->generateSignature($timestamp, $nonce, $encrypt);
|
||||||
|
|
||||||
|
// 9. 返回加密后的数据包
|
||||||
|
return [
|
||||||
|
'Encrypt' => $encrypt,
|
||||||
|
'MsgSignature' => $msgSignature,
|
||||||
|
'TimeStamp' => $timestamp,
|
||||||
|
'Nonce' => $nonce,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- 内部辅助方法 -------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成消息签名(使用token、timestamp、nonce、encrypt)
|
||||||
|
* @param string $timestamp 时间戳
|
||||||
|
* @param string $nonce 随机数
|
||||||
|
* @param string $encrypt 密文
|
||||||
|
* @return string 签名字符串
|
||||||
|
*/
|
||||||
|
private function generateSignature(string $timestamp, string $nonce, string $encrypt): string
|
||||||
|
{
|
||||||
|
$params = [$this->token, $timestamp, $nonce, $encrypt];
|
||||||
|
sort($params, SORT_STRING); // 字典序排序
|
||||||
|
$str = implode('', $params); // 拼接字符串
|
||||||
|
return sha1($str); // SHA1哈希
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机字符串(用于nonce)
|
||||||
|
* @param int $length 长度
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function generateNonce(int $length = 8): string
|
||||||
|
{
|
||||||
|
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
$nonce = '';
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$nonce .= $chars[mt_rand(0, strlen($chars) - 1)];
|
||||||
|
}
|
||||||
|
return $nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCS#7填充(按16字节块)
|
||||||
|
* @param string $text 待填充的数据
|
||||||
|
* @return string 填充后的数据
|
||||||
|
*/
|
||||||
|
private function pkcs7Pad(string $text): string
|
||||||
|
{
|
||||||
|
$blockSize = 16; // AES块大小固定16字节
|
||||||
|
$padLen = $blockSize - (strlen($text) % $blockSize);
|
||||||
|
$pad = str_repeat(chr($padLen), $padLen);
|
||||||
|
return $text . $pad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去除PKCS#7填充
|
||||||
|
* @param string $text 解密后的数据(已去除OPENSSL_ZERO_PADDING的填充)
|
||||||
|
* @return string 去除填充后的数据
|
||||||
|
* @throws Exception 当填充格式错误时抛出异常
|
||||||
|
*/
|
||||||
|
private function pkcs7Unpad(string $text): string
|
||||||
|
{
|
||||||
|
$len = strlen($text);
|
||||||
|
if ($len == 0) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
$padLen = ord($text[$len - 1]); // 最后一个字节表示填充的长度
|
||||||
|
if ($padLen < 1 || $padLen > 16) {
|
||||||
|
throw new Exception('无效的PKCS#7填充');
|
||||||
|
}
|
||||||
|
// 验证填充字节是否一致(简化验证,可根据需要加强)
|
||||||
|
for ($i = 0; $i < $padLen; $i++) {
|
||||||
|
if (ord($text[$len - 1 - $i]) != $padLen) {
|
||||||
|
throw new Exception('PKCS#7填充验证失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return substr($text, 0, $len - $padLen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace wchat\wx\V3\Libs;
|
||||||
|
|
||||||
|
class XPayGoodsDeliverNotify
|
||||||
|
{
|
||||||
|
public string $ToUserName;
|
||||||
|
public string $FromUserName;
|
||||||
|
public int $CreateTime;
|
||||||
|
public string $MsgType;
|
||||||
|
public string $Event;
|
||||||
|
public string $OpenId;
|
||||||
|
public string $OutTradeNo;
|
||||||
|
public WeChatPayInfo $WeChatPayInfo;
|
||||||
|
public int $Env;
|
||||||
|
public GoodsInfo $GoodsInfo;
|
||||||
|
public int $RetryTimes;
|
||||||
|
|
||||||
|
public function __construct(array $data)
|
||||||
|
{
|
||||||
|
$this->ToUserName = $data['ToUserName'];
|
||||||
|
$this->FromUserName = $data['FromUserName'];
|
||||||
|
$this->CreateTime = $data['CreateTime'];
|
||||||
|
$this->MsgType = $data['MsgType'];
|
||||||
|
$this->Event = $data['Event'];
|
||||||
|
$this->OpenId = $data['OpenId'];
|
||||||
|
$this->OutTradeNo = $data['OutTradeNo'];
|
||||||
|
$this->WeChatPayInfo = new WeChatPayInfo($data['WeChatPayInfo']);
|
||||||
|
$this->Env = $data['Env'];
|
||||||
|
$this->GoodsInfo = new GoodsInfo($data['GoodsInfo']);
|
||||||
|
$this->RetryTimes = $data['RetryTimes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JSON 字符串创建对象
|
||||||
|
*/
|
||||||
|
public static function fromJson(string $json): self
|
||||||
|
{
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
return new self($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace wchat\wx\V3;
|
||||||
|
|
||||||
|
use Kiri\Client;
|
||||||
|
use wchat\wx\SmallProgram;
|
||||||
|
use wchat\wx\V3\Libs\XPayGoodsDeliverNotify;
|
||||||
|
|
||||||
|
class WxVirtualPayment extends SmallProgram
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $session_key
|
||||||
|
* @param string $projectId
|
||||||
|
* @param float $price
|
||||||
|
* @param string $orderNo
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function payment(string $session_key, string $projectId, float $price, string $orderNo, array $attach = []): array
|
||||||
|
{
|
||||||
|
$signData = [
|
||||||
|
'offerId' => $this->getPayConfig()->pay->wx->offerId,
|
||||||
|
'buyQuantity' => 1,
|
||||||
|
'env' => 0,
|
||||||
|
'currencyType' => 'CNY',
|
||||||
|
'productId' => $projectId,
|
||||||
|
'goodsPrice' => $price,
|
||||||
|
'outTradeNo' => $orderNo,
|
||||||
|
'attach' => json_encode($attach, JSON_UNESCAPED_UNICODE),
|
||||||
|
];
|
||||||
|
|
||||||
|
$signJson = json_encode($signData);
|
||||||
|
return [
|
||||||
|
'signData' => $signData,
|
||||||
|
'mode' => 'short_series_goods',
|
||||||
|
'paySig' => $this->paySign('requestVirtualPayment', $signJson),
|
||||||
|
'signature' => $this->sign($session_key, $signJson),
|
||||||
|
'session_key' => $session_key,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***
|
||||||
|
* @param string $prefix
|
||||||
|
* @param string $data
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function paySign(string $prefix, string $data): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $prefix . '&' . $data, $this->getPayConfig()->pay->wx->appKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $session_key
|
||||||
|
* @param string $data
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function sign(string $session_key, string $data): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $data, $session_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $openId
|
||||||
|
* @param string $orderNo
|
||||||
|
* @return bool|array
|
||||||
|
*/
|
||||||
|
public function queryOrder(string $openId, string $orderNo): bool|array
|
||||||
|
{
|
||||||
|
$data = json_encode(['openid' => $openId, 'order_id' => $orderNo, 'env' => 0], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$client = new Client('api.weixin.qq.com', 443, true);
|
||||||
|
$client->post('/xpay/query_order?access_token=' . $this->payConfig->getAccessToken() . '&pay_sig=' . $this->paySign('/xpay/query_order', $data), $data);
|
||||||
|
$resp = $client->getBody();
|
||||||
|
|
||||||
|
$client->close();
|
||||||
|
|
||||||
|
return json_decode($resp, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $orderNo
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function notify_provide_goods(string $orderNo): array
|
||||||
|
{
|
||||||
|
$data = json_encode(['order_id' => $orderNo, 'env' => 0], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$client = new Client('api.weixin.qq.com', 443, true);
|
||||||
|
$client->post('/xpay/notify_provide_goods?access_token=' . $this->payConfig->getAccessToken() . '&pay_sig=' . $this->paySign('/xpay/notify_provide_goods', $data), $data);
|
||||||
|
$resp = $client->getBody();
|
||||||
|
$client->close();
|
||||||
|
|
||||||
|
return json_decode($resp, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user