256 lines
9.7 KiB
PHP
256 lines
9.7 KiB
PHP
<?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 pay.wxpay_*) > config/services.php(env)
|
||
*/
|
||
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)
|
||
*
|
||
* @param string $paymentNo 商户订单号(本系统支付单号)
|
||
* @param string $amountYuan 金额(元,两位小数)
|
||
* @param string $openid 付款人 openid
|
||
* @param string $description 订单标题
|
||
* @return array<string, mixed> wx.requestPayment 调起参数(timeStamp/nonceStr/package/signType/paySign)
|
||
* @throws RepositoryException 未配置或下单失败
|
||
*/
|
||
public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description): array
|
||
{
|
||
$result = $this->call('POST', '/v3/pay/transactions/jsapi', [
|
||
'appid' => $this->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');
|
||
}
|
||
|
||
$params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $this->appid());
|
||
Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId]);
|
||
|
||
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) 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.php(env)
|
||
*/
|
||
protected function config(string $key): string
|
||
{
|
||
$value = site_config('pay.wxpay_' . $key, '');
|
||
if ($value === null || $value === '') {
|
||
$value = config('services.wxpay.' . $key, '');
|
||
}
|
||
return trim((string) $value);
|
||
}
|
||
}
|