Files
appraisal_center_api/app/common/service/WechatPayV3Client.php
2026-04-16 11:17:18 +08:00

277 lines
11 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
namespace app\common\service;
use app\common\model\WechatMerchant;
class WechatPayV3Client
{
private WechatMerchant $merchant;
public function __construct(WechatMerchant $merchant)
{
$this->merchant = $merchant;
}
public function createNativeTransaction(string $outTradeNo, string $description, int $amountFen, string $notifyUrl): array
{
$body = $this->buildPayBody($outTradeNo, $description, $amountFen, $notifyUrl);
$resp = $this->request('POST', '/v3/pay/transactions/native', $body);
$data = json_decode($resp['body'], true) ?: [];
if ($resp['status'] >= 200 && $resp['status'] < 300) {
return $data;
}
$message = $data['message'] ?? ('微信支付下单失败 HTTP ' . $resp['status']);
throw new \RuntimeException($message);
}
public function createJsapiTransaction(string $outTradeNo, string $description, int $amountFen, string $notifyUrl, string $openid): array
{
$body = $this->buildJsapiPayBody($outTradeNo, $description, $amountFen, $notifyUrl, $openid);
$resp = $this->request('POST', '/v3/pay/transactions/jsapi', $body);
$data = json_decode($resp['body'], true) ?: [];
if ($resp['status'] >= 200 && $resp['status'] < 300) {
return $data;
}
$message = $data['message'] ?? ('微信支付下单失败 HTTP ' . $resp['status']);
throw new \RuntimeException($message);
}
public function buildJsapiPayParams(string $appId, string $prepayId): array
{
$mchId = (string)$this->merchant->mch_id;
$serialNo = (string)($this->merchant->serial_no ?? '');
$privateKeyPem = (string)($this->merchant->private_key_pem ?? '');
if ($privateKeyPem === '') {
$privateKeyPem = $this->loadKeyPemFromFile();
}
if ($mchId === '' || $serialNo === '' || $privateKeyPem === '') {
throw new \RuntimeException('微信支付密钥未配置mch_id/serial_no/private_key_pem');
}
$timeStamp = (string)time();
$nonceStr = bin2hex(random_bytes(16));
$package = 'prepay_id=' . $prepayId;
$signStr = $appId . "\n" . $timeStamp . "\n" . $nonceStr . "\n" . $package . "\n";
$privateKey = openssl_pkey_get_private($privateKeyPem);
if (!$privateKey) {
throw new \RuntimeException('私钥格式错误');
}
openssl_sign($signStr, $signature, $privateKey, OPENSSL_ALGO_SHA256);
return [
'appId' => $appId,
'timeStamp' => $timeStamp,
'nonceStr' => $nonceStr,
'package' => $package,
'signType' => 'RSA',
'paySign' => base64_encode($signature),
];
}
public function verifyPlatformSignature(string $timestamp, string $nonce, string $body, string $signature): bool
{
$certPath = getenv('WECHATPAY_PLATFORM_CERT_PATH') ?: '';
if ($certPath === '' || !file_exists($certPath)) {
throw new \RuntimeException('平台证书未配置');
}
$cert = file_get_contents($certPath);
$publicKey = openssl_pkey_get_public($cert);
if (!$publicKey) {
throw new \RuntimeException('平台证书读取失败');
}
$message = $timestamp . "\n" . $nonce . "\n" . $body . "\n";
$ok = openssl_verify($message, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256);
return $ok === 1;
}
public function decryptNotifyResource(array $resource, string $apiV3Key): array
{
$ciphertext = (string)($resource['ciphertext'] ?? '');
$nonce = (string)($resource['nonce'] ?? '');
$aad = (string)($resource['associated_data'] ?? '');
if ($ciphertext === '' || $nonce === '') {
throw new \RuntimeException('回调报文不完整');
}
$cipherRaw = base64_decode($ciphertext);
$tag = substr($cipherRaw, -16);
$data = substr($cipherRaw, 0, -16);
$plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
if ($plain === false) {
throw new \RuntimeException('回调报文解密失败');
}
return json_decode($plain, true) ?: [];
}
private function buildPayBody(string $outTradeNo, string $description, int $amountFen, string $notifyUrl): array
{
$mode = (string)($this->merchant->mode ?? 'direct');
$mchId = (string)$this->merchant->mch_id;
$appId = (string)($this->merchant->app_id ?? '');
$subMchId = (string)($this->merchant->sub_mch_id ?? '');
$subAppId = (string)($this->merchant->sub_app_id ?? '');
if ($mode === 'direct') {
if ($appId === '') {
throw new \RuntimeException('商户 AppID 未配置');
}
return [
'appid' => $appId,
'mchid' => $mchId,
'description' => $description,
'out_trade_no' => $outTradeNo,
'notify_url' => $notifyUrl,
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
];
}
if ($mode === 'service_provider') {
if ($appId === '' || $subMchId === '') {
throw new \RuntimeException('服务商模式配置不完整');
}
$body = [
'sp_appid' => $appId,
'sp_mchid' => $mchId,
'sub_mchid' => $subMchId,
'description' => $description,
'out_trade_no' => $outTradeNo,
'notify_url' => $notifyUrl,
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
];
if ($subAppId !== '') {
$body['sub_appid'] = $subAppId;
}
return $body;
}
throw new \RuntimeException('第三方支付模式未配置下单方式');
}
private function buildJsapiPayBody(string $outTradeNo, string $description, int $amountFen, string $notifyUrl, string $openid): array
{
$openid = trim($openid);
if ($openid === '') {
throw new \RuntimeException('openid 不能为空');
}
$mode = (string)($this->merchant->mode ?? 'direct');
$mchId = (string)$this->merchant->mch_id;
$appId = (string)($this->merchant->app_id ?? '');
$subMchId = (string)($this->merchant->sub_mch_id ?? '');
$subAppId = (string)($this->merchant->sub_app_id ?? '');
if ($mode === 'direct') {
if ($appId === '') {
throw new \RuntimeException('商户 AppID 未配置');
}
return [
'appid' => $appId,
'mchid' => $mchId,
'description' => $description,
'out_trade_no' => $outTradeNo,
'notify_url' => $notifyUrl,
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
'payer' => ['openid' => $openid],
];
}
if ($mode === 'service_provider') {
if ($appId === '' || $subMchId === '') {
throw new \RuntimeException('服务商模式配置不完整');
}
$body = [
'sp_appid' => $appId,
'sp_mchid' => $mchId,
'sub_mchid' => $subMchId,
'description' => $description,
'out_trade_no' => $outTradeNo,
'notify_url' => $notifyUrl,
'amount' => ['total' => $amountFen, 'currency' => 'CNY'],
'payer' => [],
];
if ($subAppId !== '') {
$body['sub_appid'] = $subAppId;
$body['payer']['sub_openid'] = $openid;
} else {
$body['payer']['sp_openid'] = $openid;
}
return $body;
}
throw new \RuntimeException('第三方支付模式未配置下单方式');
}
private function request(string $method, string $path, array $body): array
{
$mchId = (string)$this->merchant->mch_id;
$serialNo = (string)($this->merchant->serial_no ?? '');
$privateKeyPem = (string)($this->merchant->private_key_pem ?? '');
if ($privateKeyPem === '') {
$privateKeyPem = $this->loadKeyPemFromFile();
}
if ($mchId === '' || $serialNo === '' || $privateKeyPem === '') {
throw new \RuntimeException('微信支付密钥未配置mch_id/serial_no/private_key_pem');
}
$bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE);
$timestamp = (string)time();
$nonceStr = bin2hex(random_bytes(16));
$signStr = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonceStr . "\n" . $bodyJson . "\n";
$privateKey = openssl_pkey_get_private($privateKeyPem);
if (!$privateKey) {
throw new \RuntimeException('私钥格式错误');
}
openssl_sign($signStr, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$signature = base64_encode($signature);
$authorization = sprintf(
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"',
$mchId,
$nonceStr,
$timestamp,
$serialNo,
$signature
);
$ch = curl_init('https://api.mch.weixin.qq.com' . $path);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json',
'Authorization: ' . $authorization,
'User-Agent: anxinyan-webman',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
$respBody = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($respBody === false) {
$err = curl_error($ch);
curl_close($ch);
throw new \RuntimeException('微信支付请求失败: ' . $err);
}
curl_close($ch);
return [
'status' => $status,
'body' => $respBody,
];
}
private function loadKeyPemFromFile(): string
{
$path = (string)($this->merchant->apiclient_key_path ?? '');
if ($path === '') return '';
$real = realpath($path);
$base = realpath(runtime_path() . '/wechatpay/merchants');
if (!$real || !$base) return '';
if (strpos($real, $base) !== 0) return '';
if (!is_file($real)) return '';
$pem = file_get_contents($real);
return is_string($pem) ? $pem : '';
}
}