Files

222 lines
8.4 KiB
PHP
Raw Permalink Normal View History

<?php
namespace App\Service\wx;
use App\Models\business\OrderModel;
use App\Models\business\OrderPaymentModel;
use App\Service\business\OrderCoreService;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* 微信支付 JSAPIAPIv3
*
* 本期只把流程写完商户号可以留空configured() 为假时下单接口直接给出明确提示,
* 不会半截调用微信。等拿到商户资料,只需在后台补齐 nl_wx_app mch_* 字段即可启用。
*
* 三个不能省的点:金额单位是分(微信也用分,正好不用换算);
* 回调必须验签;回调必须幂等(靠 out_trade_no 唯一索引 + 状态判断)。
*/
class WxPayService
{
private static mixed $_instance;
private string $baseUrl = 'https://api.mch.weixin.qq.com';
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 商户资料是否齐全
*/
public function configured(array $app): bool
{
return ($app['mch_id'] ?? '') !== ''
&& ($app['mch_serial_no'] ?? '') !== ''
&& ($app['mch_private_key'] ?? '') !== ''
&& ($app['notify_url'] ?? '') !== '';
}
/**
* JSAPI 下单,返回小程序 wx.requestPayment 所需参数
*
* @param array $order 订单行
* @param string $openId 支付用户的 openid
*/
public function jsapiOrder(array $order, string $openId): array
{
$app = WxAppService::getInstance()->current();
if (!$this->configured($app)) {
UtilsService::getInstance()->errorThrow('微信支付商户信息未配置,请先使用转账凭证支付');
}
$amount = (int) $order['total_amount'] - (int) $order['paid_amount'];
if ($amount <= 0) {
UtilsService::getInstance()->errorThrow('该订单无需支付');
}
// 同一订单可能多次发起支付(用户中途放弃),每次都要新的 out_trade_no
// 否则微信会以「订单号重复」拒单,而它又必须唯一以便回调对账
$outTradeNo = 'WX' . $order['id'] . 'T' . time();
OrderPaymentModel::insert([
'order_id' => (int) $order['id'],
'pay_type' => OrderModel::PAY_TYPE_WECHAT,
'amount' => $amount,
'out_trade_no' => $outTradeNo,
'status' => OrderPaymentModel::STATUS_AUDITING,
'created_at' => time(),
]);
$body = [
'appid' => $app['app_id'],
'mchid' => $app['mch_id'],
'description' => '订单 ' . $order['order_no'],
'out_trade_no' => $outTradeNo,
'notify_url' => $app['notify_url'],
'amount' => ['total' => $amount, 'currency' => 'CNY'],
'payer' => ['openid' => $openId],
];
$path = '/v3/pay/transactions/jsapi';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
$response = Http::withHeaders([
'Authorization' => $this->authorization('POST', $path, $payload, $app),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])->withBody($payload, 'application/json')->post($this->baseUrl . $path);
$result = $response->json();
if (empty($result['prepay_id'])) {
UtilsService::getInstance()->errorThrow('微信下单失败:' . ($result['message'] ?? $response->body()));
}
return $this->buildPayParams($app, (string) $result['prepay_id']);
}
/**
* 处理支付回调
*
* @param array $headers Wechatpay-*
* @param string $rawBody 原始请求体(不能用解析后的数组重新序列化,会导致验签失败)
*/
public function handleNotify(array $headers, string $rawBody): array
{
$app = WxAppService::getInstance()->current();
if (!$this->verifySignature($headers, $rawBody, $app)) {
return ['code' => 'FAIL', 'message' => '验签失败'];
}
$body = json_decode($rawBody, true) ?: [];
$resource = $body['resource'] ?? [];
$plain = $this->decryptResource($resource, (string) $app['mch_key']);
if (empty($plain['out_trade_no'])) {
return ['code' => 'FAIL', 'message' => '报文缺少订单号'];
}
if (($plain['trade_state'] ?? '') !== 'SUCCESS') {
// 非成功态直接应答成功,避免微信持续重推
return ['code' => 'SUCCESS', 'message' => 'OK'];
}
OrderCoreService::getInstance()->confirmWechatPay(
(string) $plain['out_trade_no'],
(string) ($plain['transaction_id'] ?? ''),
(int) ($plain['amount']['total'] ?? 0)
);
return ['code' => 'SUCCESS', 'message' => 'OK'];
}
/**
* 小程序端调起支付的签名参数
*/
private function buildPayParams(array $app, string $prepayId): array
{
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$package = 'prepay_id=' . $prepayId;
$message = $app['app_id'] . "\n" . $timestamp . "\n" . $nonce . "\n" . $package . "\n";
return [
'appId' => $app['app_id'],
'timeStamp' => $timestamp,
'nonceStr' => $nonce,
'package' => $package,
'signType' => 'RSA',
'paySign' => $this->sign($message, (string) $app['mch_private_key']),
];
}
/**
* APIv3 Authorization
*/
private function authorization(string $method, string $path, string $body, array $app): string
{
$timestamp = time();
$nonce = bin2hex(random_bytes(16));
$message = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";
$signature = $this->sign($message, (string) $app['mch_private_key']);
return sprintf(
'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
$app['mch_id'],
$nonce,
$timestamp,
$app['mch_serial_no'],
$signature
);
}
private function sign(string $message, string $privateKey): string
{
$key = openssl_pkey_get_private($privateKey);
if ($key === false) {
UtilsService::getInstance()->errorThrow('商户私钥无效');
}
openssl_sign($message, $signature, $key, 'sha256WithRSAEncryption');
return base64_encode($signature);
}
/**
* 回调验签
*
* 需要微信支付平台证书公钥;未配置时一律判失败,绝不能「验不了就放过」。
*/
private function verifySignature(array $headers, string $rawBody, array $app): bool
{
$publicKey = (string) ($app['platform_public_key'] ?? '');
if ($publicKey === '') {
return false;
}
$timestamp = (string) ($headers['wechatpay-timestamp'] ?? '');
$nonce = (string) ($headers['wechatpay-nonce'] ?? '');
$signature = (string) ($headers['wechatpay-signature'] ?? '');
if ($timestamp === '' || $nonce === '' || $signature === '') {
return false;
}
// 时间戳偏移超过 5 分钟视为重放
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$message = $timestamp . "\n" . $nonce . "\n" . $rawBody . "\n";
$key = openssl_pkey_get_public($publicKey);
if ($key === false) {
return false;
}
return openssl_verify($message, base64_decode($signature), $key, 'sha256WithRSAEncryption') === 1;
}
/**
* 解密回调报文AEAD_AES_256_GCM
*/
private function decryptResource(array $resource, string $apiV3Key): array
{
$ciphertext = base64_decode((string) ($resource['ciphertext'] ?? ''));
$nonce = (string) ($resource['nonce'] ?? '');
$associated = (string) ($resource['associated_data'] ?? '');
if ($ciphertext === '' || $nonce === '' || $apiV3Key === '') {
return [];
}
$tagLength = 16;
$tag = substr($ciphertext, -$tagLength);
$data = substr($ciphertext, 0, -$tagLength);
$plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associated);
return $plain === false ? [] : (json_decode($plain, true) ?: []);
}
}