diff --git a/common/libs/Wx.php b/common/libs/Wx.php index 74fb4a6..0105b35 100644 --- a/common/libs/Wx.php +++ b/common/libs/Wx.php @@ -6,6 +6,12 @@ class Wx { + public string $offerId; + + + public string $appKey; + + /** * @var string */ @@ -53,4 +59,4 @@ class Wx */ public string $SerialNumber; -} \ No newline at end of file +} diff --git a/wx/SmallProgram.php b/wx/SmallProgram.php index 811eac7..3f3fc5b 100644 --- a/wx/SmallProgram.php +++ b/wx/SmallProgram.php @@ -4,8 +4,35 @@ namespace wchat\wx; +use Exception; use wchat\common\Multiprogramming; +use wchat\wx\V3\Libs\WxMsgCrypt; +use wchat\wx\V3\Libs\XPayGoodsDeliverNotify; 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)); + } + } + + } diff --git a/wx/V3/Libs/GoodsInfo.php b/wx/V3/Libs/GoodsInfo.php new file mode 100644 index 0000000..a0aef01 --- /dev/null +++ b/wx/V3/Libs/GoodsInfo.php @@ -0,0 +1,22 @@ +ProductId = $data['ProductId']; + $this->Quantity = $data['Quantity']; + $this->OrigPrice = $data['OrigPrice']; + $this->ActualPrice = $data['ActualPrice']; + $this->Attach = $data['Attach']; + } + +} diff --git a/wx/V3/Libs/WeChatPayInfo.php b/wx/V3/Libs/WeChatPayInfo.php new file mode 100644 index 0000000..7cbdbf7 --- /dev/null +++ b/wx/V3/Libs/WeChatPayInfo.php @@ -0,0 +1,18 @@ +MchOrderNo = $data['MchOrderNo']; + $this->TransactionId = $data['TransactionId']; + $this->PaidTime = $data['PaidTime']; + } + +} diff --git a/wx/V3/Libs/WxMsgCrypt.php b/wx/V3/Libs/WxMsgCrypt.php new file mode 100644 index 0000000..fd860e0 --- /dev/null +++ b/wx/V3/Libs/WxMsgCrypt.php @@ -0,0 +1,216 @@ +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); + } +} + diff --git a/wx/V3/Libs/XPayGoodsDeliverNotify.php b/wx/V3/Libs/XPayGoodsDeliverNotify.php new file mode 100644 index 0000000..2a85000 --- /dev/null +++ b/wx/V3/Libs/XPayGoodsDeliverNotify.php @@ -0,0 +1,43 @@ +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); + } + +} diff --git a/wx/V3/WxVirtualPayment.php b/wx/V3/WxVirtualPayment.php new file mode 100644 index 0000000..cfbb350 --- /dev/null +++ b/wx/V3/WxVirtualPayment.php @@ -0,0 +1,103 @@ + $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); + } + +}