Files
xin-procurement/app/Services/WechatPayService.php
T
2026-09-04 21:22:46 +08:00

263 lines
10 KiB
PHP
Raw 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\Services;
use App\Exceptions\RepositoryException;
use EasyWeChat\Pay\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 微信官方支付服务(APIv3 小程序 JSAPI,基于 EasyWeChat Pay
*
* - 统一下单:POST /v3/pay/transactions/jsapi → prepay_id → 商户私钥签名生成 wx.requestPayment 调起参数
* - 交易查询:GET /v3/pay/transactions/out-trade-no/{商户订单号}
* - 支付通知:Wechatpay-Signature 验签(配置平台证书/公钥时)+ APIv3 密钥 AES-256-GCM 解密报文
*
* 配置优先级:后台「系统设置 → 微信官方支付」(site_config wxpay.*) > config/services.phpenv
*/
class WechatPayService
{
/** 通知应答码:成功 / 失败(微信收到 SUCCESS 才停止重推) */
public const string NOTIFY_ACK_OK = 'SUCCESS';
public const string NOTIFY_ACK_FAIL = 'FAIL';
/** 交易状态:支付成功(交易查询 / 支付通知的 trade_state */
public const string TRADE_STATE_SUCCESS = 'SUCCESS';
protected ?Application $application = null;
/**
* 统一下单(JSAPI:小程序 / 公众号 H5 通用)
*
* @param string $paymentNo 商户订单号(本系统支付单号)
* @param string $amountYuan 金额(元,两位小数)
* @param string $openid 付款人 openid(按下单 appid 维度:小程序 openid 或公众号 openid
* @param string $description 订单标题
* @param string $appid 下单应用 appid(公众号 JSAPI 传公众号 appid;留空取配置的小程序 appid)
* @return array<string, mixed> 调起支付参数(appId/timeStamp/nonceStr/package/signType/paySign
* 小程序喂 wx.requestPayment,公众号 H5 喂 WeixinJSBridge getBrandWCPayRequest,结构同构)
* @throws RepositoryException 未配置或下单失败
*/
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description, string $appid = ''): array
{
$appid = trim($appid) !== '' ? trim($appid) : $this->appid();
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
'appid' => $appid,
'mchid' => $this->config('mch_id'),
'description' => mb_substr($description, 0, 127),
'out_trade_no' => $paymentNo,
'notify_url' => $this->notifyUrl(),
'amount' => [
'total' => (int) bcmul($amountYuan, '100'), // APIv3 金额单位:分
'currency' => 'CNY',
],
'payer' => ['openid' => $openid],
], '下单');
$prepayId = (string) ($result['prepay_id'] ?? '');
if ($prepayId === '') {
Log::driver('pay')->error('微信支付下单应答缺少 prepay_id', ['response' => $result]);
throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id');
}
// buildMiniAppConfig 即 buildBridgeConfig(小程序 wx.requestPayment 与公众号 WeixinJSBridge 参数同构)
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $appid);
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId, 'appid' => $appid]);
return $params;
}
/**
* 交易查询(按商户订单号)
*
* @param string $paymentNo 商户订单号(本系统支付单号)
* @return array<string, mixed> 微信交易报文(trade_state/transaction_id/success_time/amount.total 等)
* @throws RepositoryException 查询失败
*/
public function queryOrder(string $paymentNo): array
{
return $this->call('GET', '/v3/pay/transactions/out-trade-no/' . $paymentNo, [
'mchid' => $this->config('mch_id'),
], '查询');
}
/**
* 解密支付结果后台通知(配置平台证书/公钥时先验签,再以 APIv3 密钥 AES-256-GCM 解密)
*
* @return array<string, mixed> 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount 等)
* @throws RepositoryException 验签或解密失败
*/
public function decryptNotify(Request $request): array
{
Log::driver('pay')->info('微信支付通知接收', ['body' => $request->getContent()]);
$app = $this->app();
$server = $app->getServer();
$server->setRequestFromSymfonyRequest($request);
// 配置平台证书/公钥时校验 Wechatpay-Signature;未配置时依赖 AES-GCM 认证解密(APIv3 密钥仅微信与商户持有)
if ($this->config('platform_cert') !== '' && $this->config('platform_serial') !== '') {
try {
$app->getValidator()->validate($server->getRequest());
} catch (Throwable $e) {
throw new RepositoryException('微信支付通知验签失败:' . $e->getMessage());
}
}
try {
$message = $server->getRequestMessage();
} catch (Throwable $e) {
throw new RepositoryException('微信支付通知解密失败:' . $e->getMessage());
}
$params = $message->toArray();
Log::driver('pay')->info('微信支付通知解密', ['params' => $params]);
return $params;
}
/**
* 支付结果后台通知地址(统一下单时上送的 notify_url
*/
public function notifyUrl(): string
{
return rtrim((string) config('app.url'), '/') . '/mini/payment/wechat-notify';
}
/**
* 注入 EasyWeChat Application(测试 mock 用)
*/
public function setApplication(Application $application): static
{
$this->application = $application;
return $this;
}
/**
* EasyWeChat Pay Application(按当前配置懒加载构建)
*
* @throws RepositoryException 必填配置缺失或密钥无法解析
*/
public function app(): Application
{
if ($this->application !== null) {
return $this->application;
}
$mchId = $this->config('mch_id');
if ($mchId === '') {
throw new RepositoryException('微信支付未配置商户号,请联系管理员');
}
$platformCerts = [];
$platformCert = $this->config('platform_cert');
$platformSerial = $this->config('platform_serial');
if ($platformCert !== '' && $platformSerial !== '') {
$platformCerts[$platformSerial] = $this->pemContent($platformCert, 'CERTIFICATE');
}
try {
$this->application = new Application([
'mch_id' => $mchId,
'private_key' => $this->pemContent($this->config('private_key'), 'PRIVATE'),
'certificate' => $this->pemContent($this->config('certificate'), 'CERTIFICATE'),
'secret_key' => $this->config('secret_key'),
'platform_certs' => $platformCerts,
]);
} catch (RepositoryException $e) {
throw $e;
} catch (Throwable $e) {
throw new RepositoryException('微信支付配置错误:' . $e->getMessage());
}
return $this->application;
}
/**
* 调起微信 v3 接口并处理应答(通讯/业务失败统一抛 RepositoryException
*
* @param array<string, mixed> $payload POST 时为 JSON 报文,GET 时为 query 参数
* @return array<string, mixed> 应答报文
* @throws RepositoryException
*/
protected function call(string $method, string $uri, array $payload, string $action): array
{
try {
$response = $method === 'GET'
? $this->app()->getClient()->get($uri, ['query' => $payload])
: $this->app()->getClient()->postJson($uri, $payload);
} catch (RepositoryException $e) {
throw $e;
} catch (Throwable $e) {
Log::driver('pay')->error('微信支付' . $action . '通讯异常', ['uri' => $uri, 'error' => $e->getMessage()]);
throw new RepositoryException('微信支付' . $action . '通讯异常,请稍后重试');
}
$body = $response->getContent(false);
Log::driver('pay')->info('微信支付' . $action . '应答', [
'uri' => $uri,
'status' => $response->getStatusCode(),
'body' => $body,
]);
$result = json_decode($body, true);
if ($response->isFailed()) {
$message = is_array($result) ? (string) ($result['message'] ?? $result['code'] ?? '') : '';
throw new RepositoryException('微信支付' . $action . '失败:' . ($message !== '' ? $message : '未知错误'));
}
return is_array($result) ? $result : [];
}
/**
* 下单小程序 appid(默认取小程序自身 appid
*/
protected function appid(): string
{
$appid = $this->config('appid');
if ($appid === '') {
$appid = (string) site_config('wechat.mini_appid', '');
}
if ($appid === '') {
$appid = (string) config('services.wechat.mini.appid', '');
}
return $appid;
}
/**
* 密钥/证书内容归一化为 PEM:支持完整 PEM、base64 单行、file:// 路径或本地文件路径
*
* @throws RepositoryException 未配置
*/
protected function pemContent(string $value, string $kind): string
{
$value = trim($value);
if ($value === '') {
$name = $kind === 'PRIVATE' ? '商户API私钥' : '证书';
throw new RepositoryException('微信支付未配置' . $name . ',请联系管理员');
}
if (str_starts_with($value, 'file://') || str_contains($value, '-----BEGIN')) {
return $value;
}
if (is_file($value)) {
return 'file://' . $value;
}
// base64 单行内容包装为 PEM
$header = $kind === 'PRIVATE' ? 'PRIVATE' : 'CERTIFICATE';
return "-----BEGIN {$header}-----\n" . wordwrap($value, 64, "\n", true) . "\n-----END {$header}-----";
}
/**
* 读取支付配置:后台站点配置优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wxpay.' . $key, '');
if ($value === null || $value === '') {
$value = config('services.wxpay.' . $key, '');
}
return trim((string) $value);
}
}