2026-09-23 15:15:02 +08:00

787 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace driver\pay\V3WechatPay;
use crmeb\exceptions\PayException;
use crmeb\services\CacheService;
use crmeb\services\wechat\Support;
use EasyWeChat\Kernel\Support\AesGcm;
use EasyWeChat\Pay\Application;
use EasyWeChat\Pay\Message;
use Nyholm\Psr7\Response as Psr7Response;
use think\Response as ThinkResponse;
/**
* 微信支付 V3 客户端(EasyWeChat 6.x)
*
* 替代原 crmeb/services/easywechat/v3pay 手写签名/加解密栈:
* - 请求签名(/v3/ 自动 RSA 签权)与回调验签解密由 SDK 完成,
* handleNotify 现在会执行 Wechatpay-Signature 验签(旧实现缺失);
* - 微信客户端调起参数生成走 getUtils();
* - 证书模式短期缓存已验证的平台证书,未知序列号触发刷新。
*
* 方法签名与旧 v3pay/PayClient 保持一致,Driver 调用方式不变。
*
* Class PayClient
* @package driver\pay\V3WechatPay
*/
class PayClient
{
//app支付
const API_APP_APY_URL = 'v3/pay/transactions/app';
//app支付-服务商模式
const API_APP_APY_PARTNER_URL = 'v3/pay/partner/transactions/app';
//Native下单API
const API_NATIVE_URL = 'v3/pay/transactions/native';
//Native下单API-服务商模式
const API_NATIVE_PARTNER_URL = 'v3/pay/partner/transactions/native';
//h5支付接口
const API_H5_URL = 'v3/pay/transactions/h5';
//h5支付接口-服务商模式
const API_H5_PARTNER_URL = 'v3/pay/partner/transactions/h5';
//jsapi支付接口
const API_JSAPI_URL = 'v3/pay/transactions/jsapi';
//jsapi支付接口-服务商模式
const API_JSAPI_PARTNER_URL = 'v3/pay/partner/transactions/jsapi';
//发起商家转账API
const API_BATCHES_URL = 'v3/transfer/batches';
//退款
const API_REFUND_URL = 'v3/refund/domestic/refunds';
//退款查询接口
const API_REFUND_QUERY_URL = 'v3/refund/domestic/refunds/%s';
//发起转账
const API_TRANSFER_BILLS_URL = 'v3/fund-app/mch-transfer/transfer-bills';
//查询转账
const API_TRANSFER_QUERY_URL = 'v3/fund-app/mch-transfer/transfer-bills/out-bill-no/%s';
/**
* 各渠道 appid:['app','wechat','miniprog','web','h5']
* @var array
*/
protected array $appids;
/**
* 平台公钥模式:公钥 ID(空则证书模式)
* @var string
*/
protected string $publicKeyId;
/**
* @var string
*/
protected string $type = 'JSAPI';
/** @var int 支付截止时间 */
protected int $expireTime = 0;
/**
* 设置业务订单的支付截止时间。
* @param int $expires 时间戳
* @return $this
*/
public function setExpireTime(int $expires)
{
$this->expireTime = $expires;
return $this;
}
public function __construct(protected Application $app, array $appids, string $publicKeyId = '')
{
$this->appids = $appids;
$this->publicKeyId = $publicKeyId;
}
/**
* @param string $type
* @return $this
*/
public function setType(string $type)
{
$this->type = $type;
return $this;
}
/**
* 公众号jsapi支付下单
* @param string $openid
* @param string $outTradeNo
* @param string $total
* @param string $description
* @param string $attach
* @return array
*/
public function jsapiPay(string $openid, string $outTradeNo, string $total, string $description, string $attach)
{
$appId = trim((string)($this->appids['wechat'] ?? ''));
if ($appId === '') {
throw new PayException('微信内支付需要配置微信公众号AppID;普通浏览器请使用H5支付');
}
$res = $this->pay('jsapi', $appId, $outTradeNo, $total, $description, $attach, ['openid' => $openid]);
return $this->configForJSSDKPayment($appId, $res['prepay_id']);
}
/**
* 小程序支付
* @param string $openid
* @param string $outTradeNo
* @param string $total
* @param string $description
* @param string $attach
* @return array
*/
public function miniprogPay(string $openid, string $outTradeNo, string $total, string $description, string $attach)
{
$appId = trim((string)($this->appids['miniprog'] ?? ''));
if ($appId === '') {
throw new PayException('微信支付V3小程序需要配置小程序AppID');
}
$res = $this->pay('jsapi', $appId, $outTradeNo, $total, $description, $attach, ['openid' => $openid]);
return $this->configForJSSDKPayment($appId, $res['prepay_id']);
}
/**
* APP支付下单
* @param string $outTradeNo
* @param string $total
* @param string $description
* @param string $attach
* @return array
*/
public function appPay(string $outTradeNo, string $total, string $description, string $attach)
{
$appId = trim((string)($this->appids['app'] ?? ''));
if ($appId === '') {
throw new PayException('微信支付V3 APP支付需要配置APP应用AppID');
}
$res = $this->pay('app', $appId, $outTradeNo, $total, $description, $attach);
return $this->configForAppPayment($res['prepay_id']);
}
/**
* native支付下单
* @param string $outTradeNo
* @param string $total
* @param string $description
* @param string $attach
* @return array
*/
public function nativePay(string $outTradeNo, string $total, string $description, string $attach)
{
$appId = trim((string)($this->appids['web'] ?? ''));
if ($appId === '') {
throw new PayException('微信支付V3 Native支付需要配置公众号AppID');
}
return $this->pay('native', $appId, $outTradeNo, $total, $description, $attach);
}
/**
* h5支付下单
* @param string $outTradeNo
* @param string $total
* @param string $description
* @param string $attach
* @return array
*/
public function h5Pay(string $outTradeNo, string $total, string $description, string $attach)
{
// 兼容旧调用方未传入 h5 键时沿用公众号 AppID。
$appId = trim((string)($this->appids['h5'] ?? $this->appids['wechat'] ?? ''));
if ($appId === '') {
throw new PayException('H5微信支付需要配置已绑定商户号的AppID(公众号、小程序或移动应用均可)');
}
return $this->pay('h5', $appId, $outTradeNo, $total, $description, $attach);
}
/**
* 下单
* @param string $type jsapi|native|app|h5
* @param string $appid
* @param string $outTradeNo
* @param string $total 金额(元)
* @param string $description
* @param string $attach
* @param array $payer
* @return array
*/
public function pay(string $type, string $appid, string $outTradeNo, string $total, string $description, string $attach, array $payer = [])
{
$totalFee = (int)bcmul($total, '100');
$data = [
'appid' => $appid,
'mchid' => $this->app->getConfig()->get('mch_id'),
'out_trade_no' => $outTradeNo,
'attach' => $attach,
'description' => $description,
'notify_url' => $this->app->getConfig()->get('notify_url'),
'amount' => [
'total' => $totalFee,
'currency' => 'CNY',
],
];
if ($payer) {
$data['payer'] = $payer;
}
if ($this->expireTime > 0) $data['time_expire'] = date(DATE_RFC3339, $this->expireTime);
//服务商支付模式
if ($this->app->getConfig()->get('sub_mch_id')) {
$mchid = $data['mchid'];
$subAppid = $data['appid'];
unset($data['mchid'], $data['appid'], $data['payer']);
$data['sp_appid'] = $this->app->getConfig()->get('sp_appid');
$data['sp_mchid'] = $mchid;
$data['sub_mchid'] = $this->app->getConfig()->get('sub_mch_id');
$data['sub_appid'] = $subAppid;
if (empty($data['sp_appid'])) throw new PayException('服务商支付需要配置服务商AppID');
if (!empty($payer['openid'])) {
$data['payer']['sub_openid'] = $payer['openid'];
$data['sub_appid'] = $subAppid;
}
$urls = [
'h5' => self::API_H5_PARTNER_URL,
'native' => self::API_NATIVE_PARTNER_URL,
'app' => self::API_APP_APY_PARTNER_URL,
'jsapi' => self::API_JSAPI_PARTNER_URL,
];
} else {
$urls = [
'h5' => self::API_H5_URL,
'native' => self::API_NATIVE_URL,
'app' => self::API_APP_APY_URL,
'jsapi' => self::API_JSAPI_URL,
];
}
if ($type === 'h5') {
$data['scene_info'] = [
'payer_client_ip' => request()->ip(),
'h5_info' => [
'type' => 'Wap',
],
];
}
if (empty($urls[$type])) {
throw new PayException('缺少请求地址');
}
$res = $this->requestJson($urls[$type], $data);
if (!$res) {
throw new PayException('微信支付:下单失败');
}
if (isset($res['code']) && isset($res['message'])) {
throw new PayException($res['message']);
}
return $res;
}
/**
* 发起商家转账API
* @param string $outBatchNo
* @param string $amount
* @param string $batchName
* @param string $remark
* @param array $transferDetailList
* @return array
*/
public function batches(string $outBatchNo, string $amount, string $batchName, string $remark, array $transferDetailList)
{
$totalFee = '0';
$amount = bcadd($amount, '0', 2);
foreach ($transferDetailList as &$item) {
if ($item['transfer_amount'] >= 2000 && empty($item['user_name'])) {
throw new PayException('明细金额大于等于2000时,收款人姓名必须填写');
}
$totalFee = bcadd($totalFee, $item['transfer_amount'], 2);
$item['transfer_amount'] = (int)bcmul($item['transfer_amount'], 100, 0);
if (isset($item['user_name'])) {
$item['user_name'] = $this->encryptor($item['user_name']);
}
}
if ($totalFee !== $amount) {
throw new PayException('转账明细金额总和和转账总金额不一致');
}
$amount = (int)bcmul($amount, 100, 0);
$appid = $this->appidByType();
if (!$appid) {
throw new PayException('暂时只支持微信用户、小程序用户、APP微信登录用户提现');
}
$data = [
'appid' => $appid,
'out_batch_no' => $outBatchNo,
'batch_name' => $batchName,
'batch_remark' => $remark,
'total_amount' => $amount,
'total_num' => count($transferDetailList),
'transfer_detail_list' => $transferDetailList,
];
$res = $this->requestJson(self::API_BATCHES_URL, $data);
if (!$res) {
throw new PayException('微信支付:发起商家转账失败');
}
if (isset($res['code']) && isset($res['message'])) {
throw new PayException($res['message']);
}
return $res;
}
/**
* 以固定批次号和明细号查询旧商家转账的单笔终态。
* @param string $orderNo 原批次号及明细号
* @return array
*/
public function queryBatchTransfer(string $orderNo): array
{
return $this->requestJson('v3/transfer/batches/out-batch-no/' . rawurlencode($orderNo)
. '/details/out-detail-no/' . rawurlencode($orderNo), [], 'GET');
}
/**
* 发起商家转账(单笔,转账到零钱)
* @param $order_id
* @param $transfer_scene_id
* @param $openid
* @param $user_name
* @param $transfer_amount
* @param $transfer_remark
* @param $notify_url
* @param $user_recv_perception
* @param $transfer_scene_report_infos
* @return array
*/
public function transferBills($order_id, $transfer_scene_id, $openid, $user_name, $transfer_amount, $transfer_remark, $notify_url, $user_recv_perception, $transfer_scene_report_infos)
{
$appid = $this->appidByType();
if ($appid === '') {
throw new PayException('暂时只支持微信用户、小程序用户、APP微信登录用户提现');
}
if ($transfer_amount > 200000) {
if ($user_name === '') {
throw new PayException('金额大于等于2000时,收款人姓名必须填写');
}
$user_name = $this->encryptor($user_name);
} else {
$user_name = '';
}
$data = [
'appid' => $appid,
'out_bill_no' => $order_id,
'transfer_scene_id' => $transfer_scene_id,
'openid' => $openid,
'user_name' => $user_name,
'transfer_amount' => (int)$transfer_amount,
'transfer_remark' => $transfer_remark,
'notify_url' => $notify_url,
'user_recv_perception' => $user_recv_perception,
'transfer_scene_report_infos' => $transfer_scene_report_infos,
];
$res = $this->requestJson(self::API_TRANSFER_BILLS_URL, $data);
if (!$res || isset($res['code'], $res['message'])) {
throw new PayException($res['message'] ?? '微信支付:发起商家转账失败');
}
return $res;
}
/**
* 查询转账单
* @param string $outBillNo
* @return array
*/
public function queryTransferBills(string $outBillNo)
{
$res = $this->requestJson(sprintf(self::API_TRANSFER_QUERY_URL, $outBillNo), [], 'GET');
if (!$res) {
throw new PayException('发起退款查询失败');
}
return $res;
}
/**
* 退款
* @param string $outTradeNo
* @param array $options
* @return array
*/
public function refund(string $outTradeNo, array $options = [])
{
if (!isset($options['pay_price'])) {
throw new PayException('缺少pay_price');
}
$totalFee = (int)bcmul((string)$options['pay_price'], '100', 0);
$refundFee = isset($options['refund_price']) ? (int)bcmul((string)$options['refund_price'], '100', 0) : null;
$refundReason = $options['desc'] ?? '';
$refundNo = $options['refund_id'] ?? $outTradeNo;
/*仅针对老资金流商户使用
REFUND_SOURCE_UNSETTLED_FUNDS---未结算资金退款(默认使用未结算资金退款)
REFUND_SOURCE_RECHARGE_FUNDS---可用余额退款
*/
$refundAccount = $options['refund_account'] ?? 'AVAILABLE';
// 默认参数为商户订单号;仅上层明确标记 trade_no 时才是微信支付流水号。
// 两种编号字段互斥,不能把中台 order_no 放入 transaction_id。
$orderField = ($options['type'] ?? '') === 'trade_no' ? 'transaction_id' : 'out_trade_no';
$data = [
$orderField => $outTradeNo,
'out_refund_no' => $refundNo,
'amount' => [
'refund' => (int)$refundFee,
'currency' => 'CNY',
'total' => (int)$totalFee,
],
'funds_account' => $refundAccount,
];
// 退款通知复用 V3 验签入口,支付通知地址不会自动成为退款通知地址。
$notifyUrl = trim((string)$this->app->getConfig()->get('notify_url'));
if ($notifyUrl !== '') {
$data['notify_url'] = $notifyUrl;
}
if ($refundReason) {
$data['reason'] = $refundReason;
}
//服务商支付退款
if ($this->app->getConfig()->get('sub_mch_id')) {
$data['sub_mchid'] = $this->app->getConfig()->get('sub_mch_id');
}
$res = $this->requestJson(self::API_REFUND_URL, $data);
if (!$res) {
throw new PayException('微信支付:发起退款失败');
}
if (isset($res['code']) && isset($res['message'])) {
throw new PayException($res['message']);
}
return $res;
}
/**
* 查询退款
* @param string $outRefundNo
* @return array
*/
public function queryRefund(string $outRefundNo)
{
$query = [];
if ($this->app->getConfig()->get('sub_mch_id')) {
$query['sub_mchid'] = $this->app->getConfig()->get('sub_mch_id');
}
$res = $this->requestJson(sprintf(self::API_REFUND_QUERY_URL, rawurlencode($outRefundNo)), $query, 'GET');
if (!$res) {
throw new PayException('发起退款查询失败');
}
if (isset($res['code']) && isset($res['message'])) {
throw new PayException($res['message']);
}
return $res;
}
/**
* 小程序/公众号 JSAPI 支付参数(与旧 configForJSSDKPayment 形状一致,timestamp 小写)
* @param string $appid
* @param string $prepayId
* @return array
*/
public function configForJSSDKPayment(string $appid, string $prepayId)
{
$config = $this->app->getUtils()->buildMiniAppConfig($prepayId, $appid);
$config['timestamp'] = $config['timeStamp'];
unset($config['timeStamp']);
return $config;
}
/**
* APP支付参数
* @param string $prepayId
* @return array
*/
public function configForAppPayment(string $prepayId): array
{
return $this->app->getUtils()->buildAppConfig($prepayId, $this->appids['app']);
}
/**
* 按原商户订单号查询支付结果。
* @param string $orderNo 原订单号
* @return array
*/
public function queryOrder(string $orderNo): array
{
$partner = (bool)$this->app->getConfig()->get('sub_mch_id');
$params = $partner ? ['sp_mchid' => $this->app->getConfig()->get('mch_id'),
'sub_mchid' => $this->app->getConfig()->get('sub_mch_id')] : ['mchid' => $this->app->getConfig()->get('mch_id')];
$result = $this->requestJson('v3/pay/' . ($partner ? 'partner/' : '') . 'transactions/out-trade-no/' . rawurlencode($orderNo), $params, 'GET', ['ORDER_NOT_EXIST']);
return ($result['code'] ?? '') === 'ORDER_NOT_EXIST' ? ['trade_state' => 'NOTFOUND'] : $result;
}
/**
* 关闭普通支付订单。
* @param string $orderNo 原订单号
* @return bool
*/
public function closeOrder(string $orderNo): bool
{
$partner = (bool)$this->app->getConfig()->get('sub_mch_id');
$params = $partner ? ['sp_mchid' => $this->app->getConfig()->get('mch_id'),
'sub_mchid' => $this->app->getConfig()->get('sub_mch_id')] : ['mchid' => $this->app->getConfig()->get('mch_id')];
$result = $this->requestJson('v3/pay/' . ($partner ? 'partner/' : '') . 'transactions/out-trade-no/' . rawurlencode($orderNo) . '/close', $params, 'POST', ['ORDER_CLOSED', 'ORDER_NOT_EXIST']);
return !$result || in_array($result['code'] ?? '', ['ORDER_CLOSED', 'ORDER_NOT_EXIST'], true);
}
/**
* 敏感字段 RSA 加密(收款人姓名等)
* @param string $plaintext
* @return string
*/
public function encryptor(string $plaintext): string
{
return $this->app->getUtils()->encryptWithRsaPublicKey($plaintext, $this->publicKeyId ?: null);
}
/**
* 支付/退款回调
* 中间件内先验签(Wechatpay-Signature)再交给业务回调,
* 业务回调返回 true 才应答 SUCCESS,其余结果返回 HTTP 500 供微信重试。
* @param callable $callback function(Message $message, string $eventType)
* @return ThinkResponse
*/
public function handleNotify($callback): ThinkResponse
{
return $this->handleServerNotify($callback);
}
/**
* 商家转账回调(MCHTRANSFER.BILL.FINISHED)
* @param callable $callback
* @return ThinkResponse
*/
public function handleTransferNotify($callback): ThinkResponse
{
return $this->handleServerNotify($callback);
}
/**
* 回调统一处理
* @param callable $callback
* @return ThinkResponse
*/
protected function handleServerNotify($callback): ThinkResponse
{
$this->app->setRequestFromSymfonyRequest(Support::symfonyRequest());
// 在解密和业务处理前强制验签,证书缺失也必须拒绝回调。
try {
$this->validateMessage($this->app->getRequest());
} catch (\Throwable $e) {
return Support::toThinkResponse(new Psr7Response(401, [], (string)json_encode([
'code' => 'FAIL',
'message' => '验签失败',
], JSON_UNESCAPED_UNICODE)));
}
$server = $this->app->getServer();
$server->with(function (Message $message, \Closure $next) use ($callback) {
$result = call_user_func_array($callback, [$message, $message->getEventType()]);
if ($result === true) {
return $next($message);
}
return new Psr7Response(500, [], (string)json_encode([
'code' => 'FAIL',
'message' => is_string($result) ? $result : '处理失败',
], JSON_UNESCAPED_UNICODE));
});
try {
return Support::toThinkResponse($server->serve());
} catch (\Throwable $e) {
\think\facade\Log::error('[WechatV3] 通知处理失败 type=' . get_class($e));
return Support::toThinkResponse(new Psr7Response(500, ['Content-Type' => 'application/json'], '{"code":"FAIL","message":"处理失败"}'));
}
}
/**
* 证书模式下载并验证平台证书,短期缓存且检查有效期。
* 公钥模式无需调用(直接用 v3_pay_public_key/v3_pay_public_pem 构造 platform_certs)。
* 下载或验签失败抛异常,不保存未经验证的证书。
* @param Application $app 未配置 platform_certs 的应用实例
* @param bool $force 是否强制刷新
* @return array<string, string>
*/
public static function downloadPlatformCerts(Application $app, bool $force = false): array
{
$cacheKey = 'wx_v3_platform_certs_' . $app->getConfig()->get('mch_id');
$cached = $force ? null : CacheService::get($cacheKey);
if (is_array($cached) && $cached) {
foreach ($cached as $path) {
$info = is_file($path) ? openssl_x509_parse((string)file_get_contents($path)) : false;
if (!$info || $info['validFrom_time_t'] > time() || $info['validTo_time_t'] < time() + 3600) {
$cached = null;
break;
}
}
if ($cached) {
return $cached;
}
}
try {
$response = $app->getClient()->get('v3/certificates');
if ($response->getStatusCode() !== 200) throw new PayException('微信平台证书下载失败');
$data = $response->toArray(false);
$certs = [];
$plaintextCerts = [];
foreach (($data['data'] ?? []) as $item) {
$plaintext = AesGcm::decrypt(
$item['encrypt_certificate']['ciphertext'],
(string) $app->getConfig()->get('secret_key'),
$item['encrypt_certificate']['nonce'],
$item['encrypt_certificate']['associated_data']
);
$info = openssl_x509_parse($plaintext);
if (!$info || !preg_match('/^[A-Fa-f0-9]+$/D', (string)$item['serial_no'])
|| strcasecmp((string)$info['serialNumberHex'], (string)$item['serial_no']) !== 0
|| $info['validFrom_time_t'] > time() || $info['validTo_time_t'] <= time() + 3600) {
throw new PayException('微信平台证书序列号或有效期不正确');
}
$path = root_path('runtime/pem') . 'platform_cert_' . $item['serial_no'] . '.pem';
$certs[$item['serial_no']] = $path;
$plaintextCerts[$item['serial_no']] = $plaintext;
}
if ($certs) {
// 首次下载由 APIv3 密钥认证解密后,以返回的证书验证本次下载响应。
$verifier = new Application(['platform_certs' => $plaintextCerts] + $app->getConfig()->all());
$message = new Psr7Response($response->getStatusCode(), $response->getHeaders(false), $response->getContent(false));
$timestamp = $message->getHeaderLine('Wechatpay-Timestamp');
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) throw new PayException('平台证书响应时间无效');
$verifier->getValidator()->validate($message);
foreach ($certs as $serial => $path) {
if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0700, true) && !is_dir(dirname($path))) throw new PayException('平台证书目录创建失败');
if (file_put_contents($path, $plaintextCerts[$serial], LOCK_EX) === false) throw new PayException('平台证书保存失败');
}
CacheService::set($cacheKey, $certs, 3600);
}
return $certs;
} catch (\Throwable $e) {
throw new PayException('微信平台证书下载或验签失败', [], 0, $e);
}
}
/**
* 按当前类型取 appid
* @return string
*/
protected function appidByType(): string
{
if ($this->type === 'JSAPI') {
return $this->appids['wechat'] ?? '';
}
if ($this->type === 'mini') {
return $this->appids['miniprog'] ?? '';
}
if ($this->type === 'APP') {
return $this->appids['app'] ?? '';
}
return '';
}
/**
* 发起 JSON 请求并统一返回数组(throw=false,业务错误由调用方按 code/message 判断)
* @param string $url
* @param array $data
* @param string $method
* @return array
*/
protected function requestJson(string $url, array $data = [], string $method = 'POST', array $allowedErrors = []): array
{
$client = $this->app->getClient();
if ($method === 'GET') {
$response = $client->get($url, $data ? ['query' => $data] : []);
} else {
$response = $client->postJson($url, $data);
}
$status = $response->getStatusCode();
// 微信非 2xx 错误响应可能不带签名,不能将其用于资金终态判断。
if ($status >= 200 && $status < 300) {
$this->validateMessage(new Psr7Response($status, $response->getHeaders(false), $response->getContent(false)));
} else {
if ($allowedErrors) {
$this->validateMessage(new Psr7Response($status, $response->getHeaders(false), $response->getContent(false)));
$error = $response->toArray(false);
if (is_array($error) && in_array($error['code'] ?? '', $allowedErrors, true)) return $error;
}
throw new PayException('微信支付接口请求失败 HTTP ' . $status);
}
if ($status === 204) return [];
$res = $response->toArray(false);
return is_array($res) ? $res : [];
}
/**
* 验签并仅在未知证书序列号时刷新,拒绝过期和未来签名。
* @param \Psr\Http\Message\MessageInterface $message 请求或响应
* @return void
*/
protected function validateMessage(\Psr\Http\Message\MessageInterface $message): void
{
$timestamp = $message->getHeaderLine('Wechatpay-Timestamp');
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) throw new PayException('微信支付签名时间无效');
$serial = $message->getHeaderLine('Wechatpay-Serial');
if ($serial !== '' && !$this->app->getMerchant()->getPlatformCert($serial) && $this->publicKeyId === '') {
$refreshKey = 'wx_v3_cert_refresh_attempt:' . $this->app->getConfig()->get('mch_id');
$force = !CacheService::get($refreshKey);
if ($force) CacheService::set($refreshKey, true, 60);
$certs = self::downloadPlatformCerts($this->app, $force);
$config = $this->app->getConfig()->all();
$config['platform_certs'] = $certs;
$client = $this->app->getClient();
$this->app = (new Application($config))->setClient($client);
$this->app->setRequestFromSymfonyRequest(Support::symfonyRequest());
}
if ($this->publicKeyId === '') {
$certificate = $this->app->getMerchant()->getPlatformCert($serial);
$info = $certificate ? openssl_x509_parse((string)$certificate) : false;
if (!$info || strcasecmp((string)$info['serialNumberHex'], $serial) !== 0
|| $info['validFrom_time_t'] > time() || $info['validTo_time_t'] <= time()) {
throw new PayException('微信支付平台证书无效或已过期');
}
}
$this->app->getValidator()->validate($message);
}
}