Files
lgp-admin-plus-api/app/Service/wx/WxPayService.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

222 lines
8.4 KiB
PHP
Raw Permalink 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\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) ?: []);
}
}