222 lines
8.4 KiB
PHP
222 lines
8.4 KiB
PHP
|
|
<?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;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 微信支付 JSAPI(APIv3)
|
|||
|
|
*
|
|||
|
|
* 本期只把流程写完,商户号可以留空: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) ?: []);
|
|||
|
|
}
|
|||
|
|
}
|