first commit

This commit is contained in:
wushumin
2026-04-16 11:17:18 +08:00
commit 5b9c398e68
98 changed files with 8701 additions and 0 deletions

View File

@@ -0,0 +1,276 @@
<?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 : '';
}
}