文件
yjyf/server/application/common/library/WxPayV3Service.php

1082 行
53 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\common\library;
use think\Env;
use think\Log;
/**
* 微信支付 APIv3 原生网关服务(支持微信支付公钥模式验签)
*
* 覆盖能力:
* 1. 小程序 JSAPI 统一下单、主动查单、关单、小程序调起支付二次签名
* 2. 交易原路退款与退款查询
* 3. 新版「商家转账」(用户确认模式 / 场景 1005 佣金报酬):发起转账、查询转账单、撤销转账
* 4. 微信支付 / 退款 / 商家转账 V3 异步通知验签与 AEAD_AES_256_GCM 解密(基于 OpenSSL,无需 sodium 扩展)
*/
class WxPayV3Service
{
const API_BASE = 'https://api.mch.weixin.qq.com';
/** @var string 小程序 AppID */
protected $appId = '';
/** @var string 微信支付商户号 */
protected $mchId = '';
/** @var string APIv3 密钥(32字节) */
protected $keyV3 = '';
/** @var string 微信支付公钥 ID(PUB_KEY_ID_...) */
protected $publicKeyId = '';
/** @var string 商户 API 证书文件路径 */
protected $certClientPath = '';
/** @var string 商户 API 私钥文件路径 */
protected $certKeyPath = '';
/** @var string 微信支付公钥文件路径 */
protected $publicKeyPath = '';
/** @var string 商户证书十六进制序列号 */
protected $merchantSerialNo = '';
/** @var string 公网回调根域名 */
protected $notifyDomain = '';
/** @var string 商家转账场景 ID */
protected $transferSceneId = '1005';
public function __construct(array $overrideConfig = [])
{
$this->appId = trim((string)($overrideConfig['appid'] ?? Env::get('wechat.appid', '')));
$this->mchId = trim((string)($overrideConfig['mch_id'] ?? Env::get('wechat.mch_id', '')));
$this->keyV3 = trim((string)($overrideConfig['key_v3'] ?? Env::get('wechat.key_v3', '')));
$this->publicKeyId = trim((string)($overrideConfig['public_key_id'] ?? Env::get('wechat.public_key_id', '')));
$certRel = trim((string)($overrideConfig['cert_client'] ?? (Env::get('wechat.cert_client', '') ?: '/addons/epay/certs/apiclient_cert.pem')));
$keyRel = trim((string)($overrideConfig['cert_key'] ?? (Env::get('wechat.cert_key', '') ?: '/addons/epay/certs/apiclient_key.pem')));
$pubRel = trim((string)($overrideConfig['public_key'] ?? (Env::get('wechat.public_key', '') ?: '/addons/epay/certs/public_key.pem')));
$this->certClientPath = $this->resolveCertPath($certRel);
$this->certKeyPath = $this->resolveCertPath($keyRel);
$this->publicKeyPath = $this->resolveCertPath($pubRel);
$this->notifyDomain = rtrim(trim((string)($overrideConfig['notify_domain'] ?? Env::get('wechat.notify_domain', ''))), '/');
$this->transferSceneId = trim((string)($overrideConfig['transfer_scene_id'] ?? (Env::get('wechat.transfer_scene_id', '') ?: '1005')));
}
/**
* 获取当前生效的商户号与小程序 AppID
*/
public function getMchId()
{
return $this->mchId;
}
public function getAppId()
{
return $this->appId;
}
/**
* 构建公网 HTTPS 回调 URL(严格基于配置的 notify_domain,不做硬编码域名或请求头兜底)
* @param string $path 如 /api/wxpay/notify
* @return string
*/
public function buildNotifyUrl($path)
{
$path = '/' . ltrim((string)$path, '/');
$this->assertValidNotifyDomain($this->notifyDomain);
return $this->notifyDomain . $path;
}
/**
* 校验公网回调域名必须合法有效
*/
protected function assertValidNotifyDomain($domain)
{
$domain = trim((string)$domain);
if ($domain === '') {
throw new WxPayException(
'微信支付公网回调域名(wechat.notify_domain)未配置,请在 server/.env 的 [wechat] 段补充正确的 HTTPS 公网域名',
'CONFIG_NOTIFY_DOMAIN_EMPTY'
);
}
$parsed = parse_url($domain);
$scheme = strtolower((string)($parsed['scheme'] ?? ''));
$host = strtolower((string)($parsed['host'] ?? ''));
if ($scheme !== 'https' || $host === '' || in_array($host, ['localhost', '127.0.0.1', '::1'], true) || strpos($host, '.') === false) {
throw new WxPayException(
'微信支付回调域名配置不合法(当前:' . $domain . '),必须为可公开访问的 HTTPS 域名(不支持 HTTP 或本地回环地址)',
'CONFIG_NOTIFY_DOMAIN_INVALID'
);
}
}
/**
* 1. 小程序 JSAPI 统一下单,并生成前端 wx.requestPayment 所需签名包
*
* @param array $params [
* 'out_trade_no' => string (必填, 6-32位),
* 'description' => string (必填, 商品描述),
* 'amount' => float (必填, 单位:元),
* 'openid' => string (必填, 付款用户 OpenID),
* 'attach' => string (选填, 附加数据 <=128字符),
* 'notify_url' => string (选填),
* ]
* @return array ['prepay_id' => string, 'pay_params' => array]
*/
public function createJsapiOrder(array $params)
{
$this->assertConfigReady();
$outTradeNo = trim((string)($params['out_trade_no'] ?? ''));
$desc = mb_substr(trim((string)($params['description'] ?? '优匠优服上门安装维修服务')), 0, 60);
$amountFen = (int)round(((float)($params['amount'] ?? 0)) * 100);
$openid = trim((string)($params['openid'] ?? ''));
$attach = mb_substr(trim((string)($params['attach'] ?? '')), 0, 127);
$notifyUrl = !empty($params['notify_url']) ? (string)$params['notify_url'] : $this->buildNotifyUrl('/api/wxpay/notify');
if ($outTradeNo === '' || $amountFen <= 0 || $openid === '') {
throw new \InvalidArgumentException('统一下单参数不完整(单号/金额/OpenID)');
}
$payload = [
'appid' => $this->appId,
'mchid' => $this->mchId,
'description' => $desc,
'out_trade_no' => $outTradeNo,
'notify_url' => $notifyUrl,
'amount' => [
'total' => $amountFen,
'currency' => 'CNY',
],
'payer' => [
'openid' => $openid,
],
];
if ($attach !== '') {
$payload['attach'] = $attach;
}
$resp = $this->request('POST', '/v3/pay/transactions/jsapi', $payload);
if (empty($resp['prepay_id'])) {
throw new \RuntimeException('微信统一下单未返回 prepay_id');
}
$prepayId = (string)$resp['prepay_id'];
return [
'prepay_id' => $prepayId,
'pay_params' => $this->buildMiniPayParams($prepayId),
];
}
/**
* 生成小程序 wx.requestPayment 二次签名参数包
*/
public function buildMiniPayParams($prepayId)
{
$timeStamp = (string)time();
$nonceStr = $this->generateNonceStr();
$package = 'prepay_id=' . trim((string)$prepayId);
$message = $this->appId . "\n" . $timeStamp . "\n" . $nonceStr . "\n" . $package . "\n";
$paySign = $this->signMessage($message);
return [
'appId' => $this->appId,
'timeStamp' => $timeStamp,
'nonceStr' => $nonceStr,
'package' => $package,
'signType' => 'RSA',
'paySign' => $paySign,
];
}
/**
* 2. 商户订单号主动查询微信支付状态
* @param string $outTradeNo
* @return array 微信交易单详情(含 trade_state, transaction_id, amount, success_time 等)
*/
public function queryOrderByOutTradeNo($outTradeNo)
{
$this->assertConfigReady();
$outTradeNo = trim((string)$outTradeNo);
if ($outTradeNo === '') {
throw new \InvalidArgumentException('缺少商户订单号');
}
$uri = '/v3/pay/transactions/out-trade-no/' . rawurlencode($outTradeNo) . '?mchid=' . rawurlencode($this->mchId);
return $this->request('GET', $uri);
}
/**
* 3. 关闭未支付订单
*/
public function closeOrder($outTradeNo)
{
$this->assertConfigReady();
$outTradeNo = trim((string)$outTradeNo);
if ($outTradeNo === '') {
throw new \InvalidArgumentException('关闭微信支付单失败:缺少商户支付单号 out_trade_no');
}
$uri = '/v3/pay/transactions/out-trade-no/' . rawurlencode($outTradeNo) . '/close';
$this->request('POST', $uri, ['mchid' => $this->mchId]);
return true;
}
/**
* 4. 交易原路退款申请
*
* @param array $params [
* 'out_trade_no' => string,
* 'out_refund_no' => string,
* 'refund_amount' => float (元),
* 'total_amount' => float (元, 原支付单总金额),
* 'reason' => string,
* 'notify_url' => string (选填),
* ]
* @return array 微信退款响应(refund_id, status: SUCCESS|CLOSED|PROCESSING|ABNORMAL)
*/
public function refund(array $params)
{
$this->assertConfigReady();
$outTradeNo = trim((string)($params['out_trade_no'] ?? ''));
$outRefundNo = trim((string)($params['out_refund_no'] ?? ''));
$refundFen = (int)round(((float)($params['refund_amount'] ?? 0)) * 100);
$totalFen = (int)round(((float)($params['total_amount'] ?? 0)) * 100);
$reason = mb_substr(trim((string)($params['reason'] ?? '订单取消原路退款')), 0, 80);
$notifyUrl = !empty($params['notify_url']) ? (string)$params['notify_url'] : $this->buildNotifyUrl('/api/wxpay/refundNotify');
if ($outTradeNo === '' || $outRefundNo === '' || $refundFen <= 0 || $totalFen <= 0) {
throw new \InvalidArgumentException('退款参数不完整');
}
$payload = [
'out_trade_no' => $outTradeNo,
'out_refund_no' => $outRefundNo,
'notify_url' => $notifyUrl,
'amount' => [
'refund' => $refundFen,
'total' => $totalFen,
'currency' => 'CNY',
],
];
if ($reason !== '') {
$payload['reason'] = $reason;
}
return $this->request('POST', '/v3/refund/domestic/refunds', $payload);
}
/**
* 4.1 按商户退款单号主动查询微信退款状态
* @param string $outRefundNo
* @return array
*/
public function queryRefundByOutRefundNo($outRefundNo)
{
$this->assertConfigReady();
$outRefundNo = trim((string)$outRefundNo);
if ($outRefundNo === '') {
throw new \InvalidArgumentException('缺少商户退款单号');
}
$uri = '/v3/refund/domestic/refunds/' . rawurlencode($outRefundNo);
return $this->request('GET', $uri);
}
/**
* 5. 新版微信「商家转账」——发起转账(场景 1005:佣金报酬,用户确认收款模式)
*
* @param array $params [
* 'out_bill_no' => string (必填, 字母数字 1-32位),
* 'openid' => string (必填, 收款师傅 OpenID),
* 'amount' => float (必填, 实际打款净额,单位:元),
* 'remark' => string (选填, 转账备注 <=32字符),
* 'user_name' => string (选填, 收款人实名,>=2000元时必填并自动加密),
* 'job_type' => string (选填, 岗位类型 <=32字符),
* 'reward_desc' => string (选填, 报酬说明 <=32字符),
* 'recv_perception' => string (选填, 默认"劳务报酬"),
* 'notify_url' => string (选填),
* ]
* @return array ['out_bill_no', 'transfer_bill_no', 'create_time', 'state', 'package_info']
*/
public function createTransferBill(array $params)
{
$this->assertConfigReady();
$outBillNo = preg_replace('/[^A-Za-z0-9]/', '', (string)($params['out_bill_no'] ?? ''));
$openid = trim((string)($params['openid'] ?? ''));
$amountFen = (int)round(((float)($params['amount'] ?? 0)) * 100);
$remark = mb_substr(trim((string)($params['remark'] ?? '工单安装维修服务劳务报酬')), 0, 32);
$userName = trim((string)($params['user_name'] ?? ''));
$jobType = mb_substr(trim((string)($params['job_type'] ?? '上门安装维修师傅')), 0, 32);
$rewardDesc = mb_substr(trim((string)($params['reward_desc'] ?? '平台工单安装维修劳务报酬')), 0, 32);
$perception = trim((string)($params['recv_perception'] ?? '劳务报酬'));
$sceneId = trim((string)($params['transfer_scene_id'] ?? '')) ?: $this->transferSceneId;
$notifyUrl = !empty($params['notify_url']) ? (string)$params['notify_url'] : $this->buildNotifyUrl('/api/wxpay/transferNotify');
if ($outBillNo === '' || $openid === '' || $amountFen <= 0) {
throw new \InvalidArgumentException('商家转账参数不完整(单号/OpenID/金额)');
}
$payload = [
'appid' => $this->appId,
'out_bill_no' => $outBillNo,
'transfer_scene_id' => $sceneId,
'openid' => $openid,
'transfer_amount' => $amountFen,
'transfer_remark' => $remark,
'notify_url' => $notifyUrl,
'user_recv_perception' => $perception,
'transfer_scene_report_infos' => [
[
'info_type' => '岗位类型',
'info_content' => $jobType,
],
[
'info_type' => '报酬说明',
'info_content' => $rewardDesc,
],
],
];
$extraHeaders = [];
// 微信官方规定:单笔转账 >= 2000 元(200000 分)必须传加密收款人姓名;小额不传避免 <0.3 元报错
if ($amountFen >= 200000) {
if ($userName === '') {
throw new \InvalidArgumentException('单笔转账满 2000 元必须提供收款师傅真实姓名');
}
$payload['user_name'] = $this->encryptSensitive($userName);
$extraHeaders[] = 'Wechatpay-Serial: ' . $this->publicKeyId;
}
return $this->request('POST', '/v3/fund-app/mch-transfer/transfer-bills', $payload, $extraHeaders);
}
/**
* 6. 按商户单号查询商家转账单状态
* @param string $outBillNo
* @return array
*/
public function queryTransferBill($outBillNo)
{
$this->assertConfigReady();
$outBillNo = preg_replace('/[^A-Za-z0-9]/', '', (string)$outBillNo);
if ($outBillNo === '') {
throw new \InvalidArgumentException('缺少商户转账单号');
}
$uri = '/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/' . rawurlencode($outBillNo);
return $this->request('GET', $uri);
}
/**
* 7. 撤销商家转账单(仅在用户确认收款前 WAIT_USER_CONFIRM 可撤销)
* @param string $outBillNo
* @return array ['out_bill_no', 'transfer_bill_no', 'state', 'update_time']
*/
public function cancelTransferBill($outBillNo)
{
$this->assertConfigReady();
$outBillNo = preg_replace('/[^A-Za-z0-9]/', '', (string)$outBillNo);
if ($outBillNo === '') {
throw new \InvalidArgumentException('缺少商户转账单号');
}
$uri = '/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/' . rawurlencode($outBillNo) . '/cancel';
return $this->request('POST', $uri, null);
}
/**
* 8. 验证并解密微信支付 V3 异步回调通知(支付 / 退款 / 商家转账通用)
*
* @param string $rawBody 原始 HTTP 请求体
* @param array $headers HTTP 请求头(大小写不敏感)
* @return array ['event_type' => string, 'data' => array]
*/
public function verifyAndDecryptNotify($rawBody, array $headers)
{
$this->assertConfigReady();
$normHeaders = [];
foreach ($headers as $k => $v) {
$normKey = strtolower(str_replace('_', '-', (string)$k));
if (strpos($normKey, 'http-') === 0) {
$normKey = substr($normKey, 5);
}
$normHeaders[$normKey] = is_array($v) ? (string)reset($v) : (string)$v;
}
$timestamp = $normHeaders['wechatpay-timestamp'] ?? '';
$nonce = $normHeaders['wechatpay-nonce'] ?? '';
$signature = $normHeaders['wechatpay-signature'] ?? '';
$serial = $normHeaders['wechatpay-serial'] ?? '';
if ($timestamp === '' || $nonce === '' || $signature === '' || $serial === '') {
throw new WxPayException('微信回调请求缺少签名头参数(Timestamp/Nonce/Signature/Serial)', 'NOTIFY_HEADER_MISSING', 400);
}
// 防重放:拒绝超过 5 分钟偏差的回调请求
if (abs(time() - (int)$timestamp) > 300) {
throw new WxPayException('微信回调请求时间戳已过期(与服务器时间偏差超过5分钟),请检查服务器时钟同步', 'NOTIFY_EXPIRED', 400);
}
if (!$this->verifySignature($timestamp, $nonce, (string)$rawBody, $signature, $serial)) {
throw new WxPayException('微信支付回调签名验证未通过,请核对微信支付公钥文件与公钥ID配置', 'NOTIFY_SIGN_FAIL', 401);
}
$outer = json_decode((string)$rawBody, true);
if (!is_array($outer) || empty($outer['resource']) || !is_array($outer['resource'])) {
throw new WxPayException('微信回调报文结构非法,缺少加密资源体', 'NOTIFY_INVALID_BODY', 400);
}
$decrypted = $this->decryptResource($outer['resource']);
// 核验回调主体中的商户号与 AppID 是否属于本系统配置,严防跨商户串号
$cbMchId = trim((string)($decrypted['mchid'] ?? ''));
if ($cbMchId !== '' && $cbMchId !== $this->mchId) {
throw new WxPayException('回调报文中商户号(' . $cbMchId . ')与本系统配置(' . $this->mchId . ')不匹配', 'NOTIFY_MCHID_MISMATCH', 403);
}
$cbAppId = trim((string)($decrypted['appid'] ?? ''));
if ($cbAppId !== '' && $cbAppId !== $this->appId) {
throw new WxPayException('回调报文中 AppID(' . $cbAppId . ')与本系统配置(' . $this->appId . ')不匹配', 'NOTIFY_APPID_MISMATCH', 403);
}
return [
'id' => (string)($outer['id'] ?? ''),
'event_type' => (string)($outer['event_type'] ?? ''),
'summary' => (string)($outer['summary'] ?? ''),
'data' => $decrypted,
];
}
/**
* 解密微信 V3 AEAD_AES_256_GCM 密文
* @param array $resource ['algorithm', 'ciphertext', 'nonce', 'associated_data']
* @return array
*/
public function decryptResource(array $resource)
{
if (strlen($this->keyV3) !== 32) {
throw new WxPayException('微信 APIv3 密钥长度必须为 32 位,请检查 server/.env 的 [wechat] key_v3', 'CONFIG_KEY_V3_INVALID', 500);
}
$ciphertextB64 = (string)($resource['ciphertext'] ?? '');
$nonce = (string)($resource['nonce'] ?? '');
$aad = (string)($resource['associated_data'] ?? '');
$raw = base64_decode($ciphertextB64, true);
if ($raw === false || strlen($raw) <= 16) {
throw new WxPayException('微信回调密文 Base64 解码失败或长度异常', 'NOTIFY_CIPHER_INVALID', 400);
}
$cipher = substr($raw, 0, -16);
$tag = substr($raw, -16);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $this->keyV3, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
if ($plain === false) {
throw new WxPayException('微信回调报文 AEAD_AES_256_GCM 解密失败,请确认 server/.env 的 key_v3 与微信商户平台设置的 APIv3 密钥完全一致', 'NOTIFY_DECRYPT_FAIL', 500);
}
$data = json_decode($plain, true);
if (!is_array($data)) {
throw new WxPayException('微信回调报文解密后 JSON 解析失败', 'NOTIFY_JSON_INVALID', 400);
}
return $data;
}
/**
* 使用微信支付公钥 OAEP 加密敏感信息(如转账收款人真实姓名)
*/
public function encryptSensitive($plainText)
{
$pubPem = @file_get_contents($this->publicKeyPath);
if (!$pubPem) {
throw new \RuntimeException('无法读取微信支付公钥文件');
}
$pubKey = openssl_pkey_get_public($pubPem);
if (!$pubKey) {
throw new \RuntimeException('解析微信支付公钥失败');
}
$encrypted = '';
$ok = openssl_public_encrypt((string)$plainText, $encrypted, $pubKey, OPENSSL_PKCS1_OAEP_PADDING);
if (!$ok) {
throw new \RuntimeException('敏感字段 RSA-OAEP 加密失败');
}
return base64_encode($encrypted);
}
/**
* 底层微信 APIv3 HTTP 请求(自动签名 + 成功响应公钥验签 + 错误友好化)
*
* @param string $method GET | POST
* @param string $canonicalUri 以 /v3/... 开头的路径(含 query)
* @param array|null $payload 请求体数组;null 表示空请求体
* @param array $extraHeaders 附加请求头
* @return array
*/
protected function request($method, $canonicalUri, $payload = null, array $extraHeaders = [])
{
$method = strtoupper(trim((string)$method));
$timestamp = (string)time();
$nonceStr = $this->generateNonceStr();
$bodyStr = '';
if ($method !== 'GET' && $payload !== null) {
$bodyStr = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$serialNo = $this->getMerchantSerialNo();
$signMsg = $method . "\n" . $canonicalUri . "\n" . $timestamp . "\n" . $nonceStr . "\n" . $bodyStr . "\n";
$signature = $this->signMessage($signMsg);
$authHeader = sprintf(
'Authorization: WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"',
$this->mchId,
$nonceStr,
$timestamp,
$serialNo,
$signature
);
$defaultSerialHeader = [];
if ($this->publicKeyId !== '') {
$hasSerial = false;
foreach ($extraHeaders as $eh) {
if (stripos((string)$eh, 'Wechatpay-Serial:') === 0) {
$hasSerial = true;
break;
}
}
if (!$hasSerial) {
$defaultSerialHeader[] = 'Wechatpay-Serial: ' . $this->publicKeyId;
}
}
$headers = array_merge([
$authHeader,
'Accept: application/json',
'Content-Type: application/json',
'User-Agent: YJYF-WxPayV3-Client/1.0 PHP/' . PHP_VERSION,
], $defaultSerialHeader, $extraHeaders);
$url = self::API_BASE . $canonicalUri;
$respHeaders = [];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $headerLine) use (&$respHeaders) {
$len = strlen($headerLine);
$parts = explode(':', $headerLine, 2);
if (count($parts) === 2) {
$respHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $len;
});
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
} elseif ($method !== 'GET') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($bodyStr !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
}
}
$respBody = curl_exec($ch);
if ($respBody === false) {
$err = (string)curl_error($ch);
$eno = (int)curl_errno($ch);
curl_close($ch);
Log::error("[WxPayV3] 网络/传输异常 (cURL {$eno}): " . $this->maskSensitiveLog($err) . " | URI={$canonicalUri}");
$diag = $this->translateCurlError($eno, $err, $canonicalUri);
throw new WxPayException(
$diag['message'],
'CURL_' . $eno,
0,
$eno,
$diag['uncertain'],
$this->maskSensitiveLog($err)
);
}
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$respBodyStr = (string)$respBody;
// HTTP 2xx:必须校验微信支付公钥签名(204 No Content 无响应体时也同样验签空串)
if ($httpCode >= 200 && $httpCode < 300) {
$wxTs = $respHeaders['wechatpay-timestamp'] ?? '';
$wxNonce = $respHeaders['wechatpay-nonce'] ?? '';
$wxSign = $respHeaders['wechatpay-signature'] ?? '';
$wxSerial = $respHeaders['wechatpay-serial'] ?? '';
if ($wxTs === '' || $wxSign === '' || !$this->verifySignature($wxTs, $wxNonce, $respBodyStr, $wxSign, $wxSerial)) {
Log::error("[WxPayV3] 响应验签失败 | HTTP={$httpCode} | Serial={$wxSerial} | URI={$canonicalUri}");
throw new WxPayException(
'微信支付网关响应签名校验未通过(应答公钥 ID:' . ($wxSerial ?: '空') . '),请检查微信支付公钥文件与公钥 ID 配置是否一致',
'VERIFY_SIGN_FAIL',
$httpCode,
0,
false
);
}
if ($httpCode === 204 || trim($respBodyStr) === '') {
return [];
}
$decoded = json_decode($respBodyStr, true);
if (!is_array($decoded)) {
Log::error("[WxPayV3] 响应JSON解析失败 | HTTP={$httpCode} | URI={$canonicalUri}");
throw new WxPayException('微信支付网关返回报文格式异常(非合法 JSON)', 'INVALID_RESPONSE_JSON', $httpCode, 0, true);
}
return $decoded;
}
// 非 2xx:解析微信网关错误码并映射清晰中文诊断原因
$errJson = json_decode($respBodyStr, true);
$wxCode = is_array($errJson) && isset($errJson['code']) ? strtoupper(trim((string)$errJson['code'])) : ('HTTP_' . $httpCode);
$wxMsg = is_array($errJson) && isset($errJson['message']) ? trim((string)$errJson['message']) : ('微信网关返回 HTTP ' . $httpCode);
$detailMsg = '';
if (is_array($errJson) && isset($errJson['detail']) && is_array($errJson['detail'])) {
$dField = (string)($errJson['detail']['field'] ?? '');
$dIssue = (string)($errJson['detail']['issue'] ?? '');
$detailMsg = trim($dField . ' ' . $dIssue);
}
Log::error("[WxPayV3] 网关业务报错 | HTTP={$httpCode} | code={$wxCode} | msg=" . $this->maskSensitiveLog($wxMsg) . ($detailMsg !== '' ? " | detail={$detailMsg}" : '') . " | URI={$canonicalUri}");
$friendly = $this->translateWxError($wxCode, $wxMsg, $detailMsg, $httpCode, $canonicalUri);
$isUncertain = ($httpCode >= 500 || in_array($wxCode, ['SYSTEMERROR', 'SYSTEM_ERROR', 'BIZ_ERR_NEED_RETRY', 'BANKERROR'], true));
throw new WxPayException(
$friendly,
$wxCode,
$httpCode,
0,
$isUncertain,
$this->maskSensitiveLog($wxMsg . ($detailMsg !== '' ? ' | ' . $detailMsg : ''))
);
}
/**
* 验证微信支付公钥签名
*/
public function verifySignature($timestamp, $nonce, $body, $signatureB64, $serial)
{
// 若配置了微信支付公钥 ID,则校验序列号一致(兼容未来平台证书过渡期前缀检查)
if ($this->publicKeyId !== '' && strpos((string)$serial, 'PUB_KEY_ID_') === 0 && $serial !== $this->publicKeyId) {
Log::warning("[WxPayV3] 公钥ID不匹配: 期望 {$this->publicKeyId}, 实际 {$serial}");
return false;
}
$pubPem = @file_get_contents($this->publicKeyPath);
if (!$pubPem) {
return false;
}
$pubKey = openssl_pkey_get_public($pubPem);
if (!$pubKey) {
return false;
}
$sigBin = base64_decode((string)$signatureB64, true);
if ($sigBin === false) {
return false;
}
$message = (string)$timestamp . "\n" . (string)$nonce . "\n" . (string)$body . "\n";
$verified = openssl_verify($message, $sigBin, $pubKey, OPENSSL_ALGO_SHA256);
return $verified === 1;
}
/**
* 使用商户私钥生成 SHA256-RSA2048 签名(Base64)
*/
protected function signMessage($message)
{
$keyPem = @file_get_contents($this->certKeyPath);
if (!$keyPem) {
throw new WxPayException('商户 API 私钥文件不存在或无读取权限(' . basename((string)$this->certKeyPath) . ')', 'CONFIG_CERT_KEY_MISSING');
}
$privKey = openssl_pkey_get_private($keyPem);
if (!$privKey) {
throw new WxPayException('解析商户 API 私钥失败,请确认 apiclient_key.pem 文件格式正确且未损坏', 'CONFIG_CERT_KEY_INVALID');
}
$signature = '';
$ok = openssl_sign((string)$message, $signature, $privKey, OPENSSL_ALGO_SHA256);
if (!$ok) {
throw new WxPayException('使用商户私钥生成 SHA256-RSA2048 签名失败', 'SIGN_GEN_FAIL');
}
return base64_encode($signature);
}
/**
* 从商户证书提取十六进制序列号
*/
protected function getMerchantSerialNo()
{
if ($this->merchantSerialNo !== '') {
return $this->merchantSerialNo;
}
$certPem = @file_get_contents($this->certClientPath);
if (!$certPem) {
throw new WxPayException('商户 API 证书文件不存在(' . basename((string)$this->certClientPath) . ')', 'CONFIG_CERT_CLIENT_MISSING');
}
$parsed = openssl_x509_parse($certPem);
if (!is_array($parsed)) {
throw new WxPayException('解析商户 API 证书失败,请检查 apiclient_cert.pem 是否有效', 'CONFIG_CERT_CLIENT_INVALID');
}
if (!empty($parsed['validTo_time_t']) && (int)$parsed['validTo_time_t'] < time()) {
throw new WxPayException(
'商户 API 证书已于 ' . date('Y-m-d', (int)$parsed['validTo_time_t']) . ' 过期,请登录微信商户平台重新下载并替换 apiclient_cert.pem 与 apiclient_key.pem',
'CONFIG_CERT_CLIENT_EXPIRED'
);
}
if (!empty($parsed['serialNumberHex'])) {
$this->merchantSerialNo = strtoupper((string)$parsed['serialNumberHex']);
return $this->merchantSerialNo;
}
if (!empty($parsed['serialNumber']) && function_exists('bcdiv')) {
$dec = (string)$parsed['serialNumber'];
$hex = '';
while (bccomp($dec, '0') > 0) {
$rem = (int)bcmod($dec, '16');
$hex = dechex($rem) . $hex;
$dec = bcdiv($dec, '16', 0);
}
$this->merchantSerialNo = strtoupper($hex);
return $this->merchantSerialNo;
}
throw new WxPayException('无法提取商户 API 证书序列号,请检查证书内容是否完整', 'CONFIG_CERT_SERIAL_EMPTY');
}
/**
* 严格校验全部微信支付核心参数与证书文件(不做任何缺省回退)
*/
protected function assertConfigReady()
{
$missing = [];
if ($this->appId === '') {
$missing[] = '小程序 AppID(wechat.appid)';
}
if ($this->mchId === '') {
$missing[] = '微信商户号(wechat.mch_id)';
}
if (strlen($this->keyV3) !== 32) {
$missing[] = '32位 APIv3 密钥(wechat.key_v3)';
}
if ($this->publicKeyId === '') {
$missing[] = '微信支付公钥 ID(wechat.public_key_id)';
}
if (!empty($missing)) {
throw new WxPayException(
'微信支付配置缺失或不完整:' . implode('、', $missing) . ',请检查 server/.env 的 [wechat] 配置段',
'CONFIG_INCOMPLETE'
);
}
$this->assertValidNotifyDomain($this->notifyDomain);
if (!is_file($this->certClientPath) || !is_readable($this->certClientPath)) {
throw new WxPayException('微信商户 API 证书文件缺失或不可读(' . $this->certClientPath . ')', 'CONFIG_CERT_CLIENT_MISSING');
}
if (!is_file($this->certKeyPath) || !is_readable($this->certKeyPath)) {
throw new WxPayException('微信商户 API 私钥文件缺失或不可读(' . $this->certKeyPath . ')', 'CONFIG_CERT_KEY_MISSING');
}
if (!is_file($this->publicKeyPath) || !is_readable($this->publicKeyPath)) {
throw new WxPayException('微信支付公钥文件缺失或不可读(' . $this->publicKeyPath . ')', 'CONFIG_PUB_KEY_MISSING');
}
$pubPem = @file_get_contents($this->publicKeyPath);
if (!$pubPem || !openssl_pkey_get_public($pubPem)) {
throw new WxPayException('微信支付公钥文件无法解析,请确认 public_key.pem 是否为微信商户平台下载的有效 PEM 公钥', 'CONFIG_PUB_KEY_INVALID');
}
$this->getMerchantSerialNo();
}
/**
* 从任意异常中统一提取结构化失败信息(供数据库持久化与管理员查看)
*
* @param \Throwable $e
* @return array ['code' => string, 'reason' => string, 'uncertain' => bool]
*/
public static function extractFailInfo(\Throwable $e)
{
if ($e instanceof WxPayException) {
return [
'code' => $e->getWxCode() ?: 'WXPAY_ERR',
'reason' => mb_substr(trim((string)$e->getMessage()), 0, 480),
'uncertain' => $e->isUncertain(),
];
}
$msg = trim((string)$e->getMessage());
if ($msg === '') {
$msg = '发生未知系统异常,请查看服务端系统日志';
}
return [
'code' => $e instanceof \InvalidArgumentException ? 'INVALID_PARAM' : 'SYSTEM_ERR',
'reason' => mb_substr($msg, 0, 480),
'uncertain' => false,
];
}
/**
* 将 cURL 网络传输错误码转换成管理员可读的中文原因及排查建议
*/
protected function translateCurlError($eno, $err, $uri = '')
{
$eno = (int)$eno;
$err = trim((string)$err);
$isTransfer = strpos((string)$uri, 'transfer-bills') !== false;
switch ($eno) {
case 28: // CURLE_OPERATION_TIMEDOUT
$hint = $isTransfer
? '请求微信商家转账接口超时(cURL 28):微信网关可能已受理打款请求。为严防重复出款,系统已保留当前商户转账单号,请先执行「同步微信状态」确认真实结果'
: '请求微信支付网关超时(cURL 28):微信网关可能正在处理中。系统已保留当前交易单号,请先查询或同步微信最新状态后再操作';
return ['message' => $hint, 'uncertain' => true];
case 6: // CURLE_COULDNT_RESOLVE_HOST
return [
'message' => '无法解析微信支付域名 api.mch.weixin.qq.com(cURL 6),请检查服务器 DNS 配置与外网连通性',
'uncertain' => false,
];
case 7: // CURLE_COULDNT_CONNECT
return [
'message' => '无法连接微信支付网关 443 端口(cURL 7),请检查服务器防火墙出站策略或机房网络是否阻断微信 API',
'uncertain' => false,
];
case 35: // CURLE_SSL_CONNECT_ERROR
return [
'message' => '与微信支付网关建立 TLS/SSL 安全连接失败(cURL 35),请检查服务器 OpenSSL 版本(需支持 TLSv1.2+)或网络代理',
'uncertain' => false,
];
case 58:
case 59:
return [
'message' => '本地微信商户 API 证书或私钥加载失败(cURL ' . $eno . '),请检查 apiclient_cert.pem 与 apiclient_key.pem 是否匹配或权限不足',
'uncertain' => false,
];
case 60: // CURLE_SSL_CACERT
case 77:
return [
'message' => '服务器 HTTPS 根证书校验失败(cURL ' . $eno . '):当前 PHP 环境未正确配置 curl.cainfo / openssl.cafile 信任链,系统已拒绝不安全请求,请运维配置根 CA 证书文件',
'uncertain' => false,
];
case 52: // CURLE_GOT_NOTHING
case 56: // CURLE_RECV_ERROR
return [
'message' => '微信支付网关连接异常中断,未收到完整应答(cURL ' . $eno . '),请先同步微信交易/转账状态核实后再决定后续操作',
'uncertain' => true,
];
default:
return [
'message' => '连接微信支付网关通信失败(cURL ' . $eno . '),请检查服务器网络与防火墙策略(原始诊断:' . $this->maskSensitiveLog($err) . ')',
'uncertain' => false,
];
}
}
/**
* 映射全部微信 APIv3 网关错误码与英文细节为管理员直接可读的简体中文原因及处置建议
*/
protected function translateWxError($code, $rawMsg, $detailMsg = '', $httpCode = 0, $uri = '')
{
$code = strtoupper(trim((string)$code));
$rawMsg = trim((string)$rawMsg);
$detailMsg = trim((string)$detailMsg);
$combined = strtolower($rawMsg . ' ' . $detailMsg);
$detailZh = $this->translateEnglishDetailHint($combined, $uri);
$map = [
'NOT_ENOUGH' => '微信商户运营账户可用余额不足,无法完成出款或退款。请登录微信商户平台【资金管理】充值运营账户或确保基本户可退资金充足后再试',
'NO_AUTH' => '微信商户接口权限不足,或当前服务器出口 IP 未加入微信商户平台安全 IP 白名单',
'OPENID_INVALID' => '付款或收款用户的微信 OpenID 无效,或与当前小程序 AppID 不属于同一主体,请提醒用户在微信小程序内重新登录后再试',
'USER_NOT_EXIST' => '微信账号不存在或未完成微信绑定',
'ORDERPAID' => '该商户订单已在微信完成支付,请勿重复支付',
'ORDERCLOSED' => '该微信支付单已关闭或已超时失效,请重新发起支付',
'ORDERNOTEXIST' => '微信侧未查询到该笔支付单(尚未创建或已失效)',
'ORDER_NOT_EXIST' => '微信侧未查询到该笔支付单(尚未创建或已失效)',
'NOT_FOUND' => '微信侧未找到对应的商家转账单或交易记录,可能该单此前发起失败未受理',
'RESOURCE_NOT_EXISTS' => '微信侧未找到对应单据资源,请核实商户单号是否正确或单据是否从未创建成功',
'REFUND_NOT_EXISTS' => '微信侧未找到该笔退款单记录',
'INVALID_TRANSACTIONID' => '微信交易单号无效或不属于当前商户号',
'OUT_TRADE_NO_USED' => '商户订单号已在微信重复使用且参数不一致,请重新生成新支付单号后再下单',
'ALREADY_EXISTS' => '该商户转账单号已存在但金额/收款人等参数与原单不一致,请核实原单状态切勿盲目修改参数重发',
'QUOTA_EXCEED' => '超出微信商户转账单笔、单日或单用户月度限额配置,请登录微信商户平台【商家转账-产品设置】核实并调整转账额度',
'SEND_MONEY_LIMIT' => '超出微信商户单笔或单日转账金额上限,请核对微信商户平台额度配置(如截图中的单笔200元限制)与平台提现规则是否一致',
'SEND_NUM_LIMIT' => '超出今日向同一微信用户发起转账的次数上限,请明日再试',
'TRANSFER_QUOTA_EXCEED' => '超出微信商家转账额度上限,请检查商户后台单日/单笔转账额度或用户收款限额',
'DAY_RECEIVED_QUOTA_EXCEED' => '收款用户今日累计接收商家转账已达微信单日收款额度上限',
'MONTH_RECEIVED_QUOTA_EXCEED' => '收款用户本月累计接收商家转账已达微信单月收款额度上限',
'DAY_RECEIVED_COUNT_EXCEED' => '收款用户今日接收商家转账次数已达上限',
'TRANSFER_RISK' => '本次转账触发微信支付安全风控拦截,请核实收款人微信实名状态及交易真实性后再试',
'RULE_LIMIT' => '转账或交易触发微信商户风控规则限制,请登录微信商户平台站内信或违约记录核查限制原因',
'V2_ACCOUNT_SIMPLE_BAN' => '收款人微信账户未完成实名认证(尚未绑定本人银行卡),无法接收商家转账',
'ACCOUNTERROR' => '收款人微信支付账户状态异常(可能被限制收款或注销),请提醒收款师傅核实本人微信账户',
'USER_ACCOUNT_ABNORMAL' => '收款人或付款人微信支付账户存在异常风险或已被限制交易',
'PAYEE_ACCOUNT_ABNORMAL' => '收款师傅微信账户状态异常,无法收款',
'PAYER_ACCOUNT_ABNORMAL' => '平台微信商户付款账户状态异常或受限,请登录微信商户平台排查账户通知',
'REAL_NAME_CHECK_FAIL' => '收款师傅在平台登记的真实姓名与本人微信实名信息不一致,请师傅核对真实姓名后再试',
'NAME_NOT_CORRECT' => '收款师傅真实姓名与微信实名不匹配(单笔>=2000元强制校验证实姓名)',
'PRODUCT_AUTH_CHECK_FAIL' => '当前商户号尚未开通对应产品权限,或「商家转账场景 1005 佣金报酬」尚未生效/未授权',
'APPID_MCHID_NOT_MATCH' => '微信小程序 AppID 与微信支付商户号尚未建立绑定授权关系,请前往微信商户平台【AppID账号管理】完成绑定确认',
'MCH_NOT_EXISTS' => '配置的微信支付商户号不存在,请核对 server/.env 中的 wechat.mch_id',
'SIGN_ERROR' => '微信支付请求签名校验失败,请检查商户私钥文件 apiclient_key.pem 与证书序列号、以及商户号是否配套一致',
'FREQUENCY_LIMITED' => '当前请求微信支付接口过于频繁触发限流保护,请等待数秒后重试',
'RATELIMIT_EXCEEDED' => '触发微信网关并发限流,请稍后再试',
'SYSTEMERROR' => '微信支付系统网关繁忙或内部临时错误,请勿立即切换单号重复打款,先同步核实单据状态后再重试',
'SYSTEM_ERROR' => '微信支付系统网关繁忙或内部临时错误,请勿立即切换单号重复打款,先同步核实单据状态后再重试',
'BANKERROR' => '银行通道临时异常,请稍后同步查看最终状态',
'TRADE_ERROR' => '微信交易处理失败,请提醒用户确认微信零钱或银行卡余额与状态后再重试',
'BIZ_ERR_NEED_RETRY' => '微信退款/转账业务系统临时繁忙,请使用原单号稍后同步或重试',
'PARAM_ERROR' => '请求微信网关的参数不符合微信校验规范',
'INVALID_REQUEST' => '微信支付网关拒绝当前请求(配置、白名单、状态或证书不满足前置要求)',
];
$baseZh = isset($map[$code]) ? $map[$code] : '';
if ($baseZh === '') {
if ($detailZh !== '') {
$baseZh = '微信网关拒绝请求:' . $detailZh;
} elseif ($httpCode === 401) {
$baseZh = '微信支付身份认证或签名校验未通过(HTTP 401),请检查商户号、证书序列号及私钥配置';
} elseif ($httpCode === 403) {
$baseZh = '微信支付拒绝访问(HTTP 403):请检查产品权限、转账场景授权及服务器出口 IP 白名单';
} elseif ($httpCode === 404) {
$baseZh = '微信网关未找到对应资源或接口路径(HTTP 404)';
} elseif ($httpCode >= 500) {
$baseZh = '微信支付网关服务器内部异常(HTTP ' . $httpCode . '),请先同步确认单据真实状态后再操作';
} else {
$baseZh = '微信支付接口返回失败(' . ($detailZh ?: '请根据商户平台单据状态与日志核查') . ')';
}
} elseif ($detailZh !== '' && mb_strpos($baseZh, $detailZh) === false) {
$baseZh .= ';具体原因:' . $detailZh;
}
return $baseZh . '(微信错误码:' . $code . ')';
}
/**
* 将微信 V3 英文错误 message / detail 特征精准翻译成管理员可看懂的简体中文
*/
protected function translateEnglishDetailHint($combinedLower, $uri = '')
{
if ($combinedLower === '') {
return '';
}
$hints = [];
if (strpos($combinedLower, 'ip') !== false && (strpos($combinedLower, 'white') !== false || strpos($combinedLower, 'allow') !== false || strpos($combinedLower, 'forbid') !== false || strpos($combinedLower, 'not in') !== false || strpos($combinedLower, 'whitelist') !== false || strpos($combinedLower, '白名单') !== false)) {
$hints[] = '当前服务器出口 IP 未加入微信商户平台「商家转账/API 接口安全 IP 白名单」';
}
if (strpos($combinedLower, 'openid') !== false && (strpos($combinedLower, 'appid') !== false || strpos($combinedLower, 'match') !== false || strpos($combinedLower, 'invalid') !== false || strpos($combinedLower, '不匹配') !== false)) {
$hints[] = '用户 OpenID 与当前小程序 AppID 不匹配或 OpenID 格式错误,请用户重新微信授权登录';
}
if (strpos($combinedLower, 'not enough') !== false || strpos($combinedLower, 'insufficient') !== false || strpos($combinedLower, '余额不足') !== false) {
$hints[] = '微信商户可用或可出款余额不足,请及时充值运营账户';
}
if (strpos($combinedLower, 'scene') !== false || strpos($combinedLower, 'report_info') !== false || strpos($combinedLower, 'transfer_scene') !== false) {
$hints[] = '商家转账场景 ID(应为已报备开通的 1005)或报备字段(岗位类型/报酬说明)与商户后台配置不一致';
}
if (strpos($combinedLower, 'serial') !== false || strpos($combinedLower, 'certificate') !== false || strpos($combinedLower, 'public key') !== false) {
$hints[] = '商户 API 证书序列号或微信支付公钥 ID(PUB_KEY_ID)与商户后台不一致或证书已过期';
}
if (strpos($combinedLower, 'sign') !== false && (strpos($combinedLower, 'fail') !== false || strpos($combinedLower, 'error') !== false || strpos($combinedLower, 'invalid') !== false)) {
$hints[] = 'APIv3 请求签名校验未通过,请核对 apiclient_key.pem 私钥与商户号';
}
if (strpos($combinedLower, 'timestamp') !== false || strpos($combinedLower, 'expired') !== false) {
$hints[] = '服务器系统时间与微信标准时间偏差超过 5 分钟,请校准服务器 NTP 时间';
}
if (strpos($combinedLower, 'user_name') !== false || strpos($combinedLower, 'decrypt') !== false || strpos($combinedLower, 'encrypt') !== false) {
$hints[] = '大额转账收款人姓名 RSA 加密/解密失败,请核对微信支付公钥 public_key.pem';
}
if (strpos($combinedLower, 'exceed') !== false || strpos($combinedLower, 'limit') !== false || strpos($combinedLower, 'quota') !== false || strpos($combinedLower, 'amount') !== false) {
$hints[] = '交易金额低于最低要求(如低于 0.10 元或 0.30 元限制)或超出微信单笔/单日转账与收款限额';
}
if (strpos($combinedLower, 'mchid') !== false && (strpos($combinedLower, 'appid') !== false || strpos($combinedLower, 'relation') !== false || strpos($combinedLower, 'bind') !== false)) {
$hints[] = '当前小程序 AppID 与微信商户号未完成绑定授权';
}
if (strpos($combinedLower, 'cancel') !== false && strpos($combinedLower, 'confirm') !== false) {
$hints[] = '该笔转账当前状态已不允许撤销(用户可能已完成收款或已过期关单)';
}
if (strpos($combinedLower, 'refund') !== false && strpos($combinedLower, 'more than') !== false) {
$hints[] = '申请退款金额超过原支付单剩余可退余额';
}
if (strpos($combinedLower, 'notify_url') !== false) {
$hints[] = '回调通知地址 notify_url 格式不合法或非公网可访问 HTTPS 域名';
}
if (strpos($combinedLower, 'permission') !== false || strpos($combinedLower, 'product') !== false || strpos($combinedLower, 'auth') !== false) {
$hints[] = '商户号未开通相关产品能力或未授权接口权限';
}
if (empty($hints) && preg_match('/[\x{4e00}-\x{9fa5}]/u', $combinedLower)) {
// 微信原生已返回中文提示时可直接提取保留
return $combinedLower;
}
if (empty($hints)) {
$sceneLabel = strpos((string)$uri, 'transfer') !== false ? '商家转账接口' : (strpos((string)$uri, 'refund') !== false ? '微信退款接口' : '微信支付接口');
return '调用' . $sceneLabel . '未通过微信校验,请结合错误码在微信商户平台核查';
}
return implode(';', array_unique($hints));
}
/**
* 日志敏感信息脱敏
*/
protected function maskSensitiveLog($text)
{
$text = (string)$text;
// 脱敏可能包含的 11 位手机号与 18 位身份证号
$text = preg_replace('/(?<!\d)(1\d{2})\d{4}(\d{4})(?!\d)/', '$1****$2', $text);
$text = preg_replace('/(?<!\d)(\d{6})\d{8}(\d{3}[\dXx])(?!\d)/', '$1********$2', $text);
return $text;
}
protected function generateNonceStr($bytes = 16)
{
try {
return bin2hex(random_bytes($bytes));
} catch (\Throwable $e) {
throw new WxPayException('系统安全随机数生成失败,无法构建微信支付安全签名', 'SYS_RANDOM_FAIL', 500, 0, false, '', $e);
}
}
protected function resolveCertPath($path)
{
$path = trim((string)$path);
if ($path === '') {
return '';
}
$root = defined('ROOT_PATH') ? rtrim(ROOT_PATH, '/\\') : dirname(__DIR__, 3);
// 1. 若已经是项目根目录下的绝对路径
if (strpos($path, $root) === 0 && @is_file($path)) {
return $path;
}
// 2. 拼接项目根目录后的候选绝对路径
$candidate = $root . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
if (@is_file($candidate)) {
return $candidate;
}
// 3. Windows 盘符绝对路径
if (preg_match('/^[a-zA-Z]:[\\\\\/]/', $path) && @is_file($path)) {
return $path;
}
// 4. 若以斜杠开头,避免触发 open_basedir 报错,优先返回 candidate
if (strpos($path, '/') === 0 || strpos($path, '\\') === 0) {
return $candidate;
}
// 5. 兜底检测
if (@is_file($path)) {
return $path;
}
return $candidate;
}
}