277 lines
11 KiB
PHP
277 lines
11 KiB
PHP
<?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 : '';
|
||
}
|
||
}
|