Files
xin-procurement/app/Services/WangpuPayService.php
T
2026-09-04 23:30:13 +08:00

330 lines
14 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 Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 旺铺支付网关服务(行业版接口:统一下单 / 交易查询 / 后台通知解密)
*
* 通讯协议(与官方示例 demo/IndexController.php 一致):
* - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data
* - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature
* - 整体以 JSON 信封 POST{serialNo, version, timestamp, data, signature, extras, organizNo}
* 应答报文反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。
*
* 支付/退款后台通知为明文表单报文,不涉及加解密,仅 MD5 验签:
* sign 之外所有非空数据元按名称 ASCII 升序拼成 key=value&... 串,末尾拼接 &key=加签Key
* MD5utf-8)后转大写与报文 sign 比对,一致即视为核心平台合法通知。
*
* 配置优先级:后台「系统设置 → 旺铺支付」(site_config wangpu.*) > config/services.phpenv
*/
class WangpuPayService
{
/** 网关应答成功码 */
public const string CODE_SUCCESS = '0000';
/** 通知应答码:成功(网关收到 {"code":"00"} 才认为通知成功) */
public const string NOTIFY_ACK_OK = '00';
/** 订单状态:支付成功(交易查询 / 后台通知的 order_status */
public const int ORDER_STATUS_PAID = 1;
/**
* 统一下单(小程序 JSAPI)
*
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
* @return array<string, mixed> 网关 data 解密后的业务报文(含 order_id/调起支付参数)
* @throws RepositoryException 网关应答失败
*/
public function createOrder(array $params): array
{
return $this->request('/industrial/payment/order', array_merge([
'mer_no' => $this->config('mer_no'),
'mer_code' => $this->config('mer_code'),
'term_code' => $this->config('term_code'),
'payway_code' => $this->config('payway_code'),
'organiz_no' => $this->config('organiz_no'),
], $params));
}
/**
* 交易查询(单笔订单支付状态同步)
*
* @param string $merOrderId 商户唯一订单号(本系统 payment_no
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
* @return array<string, mixed> 网关 data 解密后的业务报文
* @throws RepositoryException 网关应答失败
*/
public function queryOrder(string $merOrderId, string $orderId = ''): array
{
$params = [
'organiz_no' => $this->config('organiz_no'),
'mer_order_id' => $merOrderId,
];
if ($orderId !== '') {
$params['order_id'] = $orderId;
}
return $this->request('/industrial/query/order', $params);
}
/**
* 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知)
*
* @param array<string, mixed> $params 通知表单报文(含 sign
* @return array<string, mixed> 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等)
* @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败
*/
public function verifyNotify(array $params): array
{
Log::driver('pay')->info('旺铺支付通知接收', ['params' => $params]);
$sign = strtoupper(trim((string) ($params['sign'] ?? '')));
if ($sign === '' || ! hash_equals($this->sign($params), $sign)) {
Log::driver('pay')->warning('旺铺支付通知验签失败', ['params' => $params]);
throw new RepositoryException('通知报文验签失败');
}
return $params;
}
/**
* 通知报文 MD5 加签:剔除 sign 与空值 → 按参数名 ASCII 升序拼 key=value&... → 末尾拼 &key=加签Key → MD5 大写
*
* @param array<string, mixed> $params 通知报文
* @throws RepositoryException 加签Key未配置
*/
protected function sign(array $params): string
{
unset($params['sign']);
$params = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== '');
ksort($params, SORT_STRING);
$str = implode('&', array_map(static fn (string $key, mixed $value): string => $key . '=' . $value, array_keys($params), $params));
$signKey = $this->config('sign_key');
if ($signKey === '') {
throw new RepositoryException('旺铺支付未配置通知加签Key,请联系管理员');
}
return strtoupper(md5($str . '&key=' . $signKey));
}
/**
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
*/
public function notifyUrl(): string
{
return rtrim((string) config('app.url'), '/') . '/index.php/mini/payment/notify';
}
/**
* 支付方式代码(公众号 H5 场景优先取 mp_payway_code,留空回退 payway_code
*/
public function paywayCode(string $scene = 'mini'): string
{
if ($scene === 'mp') {
$mpPayway = $this->config('mp_payway_code');
if ($mpPayway !== '') {
return $mpPayway;
}
}
return $this->config('payway_code');
}
/**
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
*
* @param array<string, mixed> $params 接口业务参数
* @return array<string, mixed> 应答 data 解密后的业务报文
* @throws RepositoryException 通讯失败 / 应答码非成功 / 应答解密失败
*/
protected function request(string $path, array $params): array
{
$baseUrl = rtrim($this->config('base_url'), '/');
if ($baseUrl === '') {
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
}
$envelope = $this->encryptEnvelope($params);
Log::driver('pay')->info('旺铺网关请求', ['path' => $path, 'params' => $params, 'envelope' => $envelope]);
$response = Http::timeout(15)->post($baseUrl . $path, $envelope);
Log::driver('pay')->info('旺铺网关响应', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
$result = $response->json();
if (! is_array($result)) {
Log::driver('pay')->error('旺铺网关应答异常', [
'path' => $path,
'status' => $response->status(),
'body' => $response->body(),
]);
throw new RepositoryException('支付网关通讯异常,请稍后重试');
}
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
Log::driver('pay')->warning('旺铺网关应答失败', [
'path' => $path,
'response' => $result,
]);
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
}
try {
$data = $this->decryptEnvelope($result);
} catch (RepositoryException $e) {
Log::driver('pay')->error('旺铺网关应答解密失败', ['path' => $path, 'error' => $e->getMessage()]);
throw new RepositoryException('支付网关应答解密失败,请联系管理员');
}
Log::driver('pay')->info('旺铺网关应答解密', ['path' => $path, 'data' => $data]);
return $data;
}
/**
* 业务报文加密装信封:AES-128-ECB 加密报文 + 平台公钥加密 AES 密钥
*
* @param array<string, mixed> $params 业务参数
* @return array<string, string> 请求信封
* @throws RepositoryException 加密失败
*/
protected function encryptEnvelope(array $params): array
{
$key = Str::random(16); // AES 密钥(16 字节)
return [
'serialNo' => Str::random(32), // 请求流水号
'version' => '1.0',
'timestamp' => now()->format('YmdHis'),
'data' => urlencode($this->aesEncrypt($params, $key)),
'signature' => urlencode($this->rsaPublicEncrypt($key)),
'extras' => '',
'organizNo' => $this->config('organiz_no'),
];
}
/**
* 解密网关信封:商户私钥解 signature 得 AES 密钥 → AES 解 data → JSON
*
* @param array<string, mixed> $envelope
* @return array<string, mixed>
* @throws RepositoryException 解密失败
*/
protected function decryptEnvelope(array $envelope): array
{
$data = (string) ($envelope['data'] ?? '');
$signature = (string) ($envelope['signature'] ?? '');
if ($data === '' || $signature === '') {
throw new RepositoryException('报文缺少 data/signature 字段');
}
$plain = $this->aesDecrypt($data, $this->rsaPrivateDecrypt($signature));
$decoded = json_decode($plain, true);
if (! is_array($decoded)) {
throw new RepositoryException('报文 JSON 解析失败');
}
return $decoded;
}
/**
* AES-128-ECB 加密(base64 输出,与官方示例算法一致)
*
* @param array<string, mixed> $data
* @throws RepositoryException 加密失败
*/
protected function aesEncrypt(array $data, string $key): string
{
$cipher = openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
if ($cipher === false) {
throw new RepositoryException('报文 AES 加密失败');
}
return base64_encode($cipher);
}
/**
* AES-128-ECB 解密(base64 输入,与官方示例算法一致)
*
* @throws RepositoryException 解密失败
*/
protected function aesDecrypt(string $cipherBase64, string $key): string
{
$plain = openssl_decrypt((string) base64_decode($cipherBase64), 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
if (! is_string($plain)) {
throw new RepositoryException('报文 AES 解密失败');
}
return $plain;
}
/**
* RSA 平台公钥加密(base64 输出,用于加密 AES 密钥)
*
* @throws RepositoryException 加密失败
*/
protected function rsaPublicEncrypt(string $data): string
{
if (! openssl_public_encrypt($data, $encrypted, $this->pemKey('public_key', 'PUBLIC'))) {
throw new RepositoryException('旺铺平台公钥加密失败,请检查密钥配置');
}
return base64_encode($encrypted);
}
/**
* RSA 商户私钥解密(base64 输入,用于解出 AES 密钥)
*
* @throws RepositoryException 解密失败
*/
protected function rsaPrivateDecrypt(string $cipherBase64): string
{
$ok = openssl_private_decrypt((string) base64_decode($cipherBase64), $decrypted, $this->pemKey('private_key', 'PRIVATE'));
if (! $ok || ! is_string($decrypted)) {
throw new RepositoryException('商户私钥解密失败,请检查密钥配置');
}
return $decrypted;
}
/**
* 读取密钥并包装为 PEM 格式(配置存 base64 单行或完整 PEM 均可,自动剔除头尾与空白字符)
*
* 预检密钥有效性(避免 openssl 抛警告式错误),并识别公/私钥填反的常见配置错误
*
* @throws RepositoryException 未配置或密钥无法解析
*/
protected function pemKey(string $configKey, string $kind): string
{
$name = $kind === 'PUBLIC' ? '旺铺平台公钥' : '商户RSA私钥';
$body = (string) preg_replace('/-----[A-Z ]*KEY-----|\s+/', '', $this->config($configKey));
if ($body === '') {
throw new RepositoryException('旺铺支付未配置' . $name . ',请联系管理员');
}
$wrap = static fn (string $header): string => "-----BEGIN {$header} KEY-----\n"
. wordwrap($body, 64, "\n", true) . "\n-----END {$header} KEY-----";
if ($kind === 'PUBLIC') {
// 公钥兼容 SPKIPUBLIC KEY)与 PKCS#1RSA PUBLIC KEY)两种格式
foreach (['PUBLIC', 'RSA PUBLIC'] as $header) {
if (openssl_pkey_get_public($wrap($header)) !== false) {
return $wrap($header);
}
}
$isPrivate = openssl_pkey_get_private($wrap('PRIVATE')) !== false;
throw new RepositoryException($name . '配置错误' . ($isPrivate ? '(当前内容是私钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
}
$pem = $wrap('PRIVATE');
if (openssl_pkey_get_private($pem) === false) {
$isPublic = openssl_pkey_get_public($wrap('PUBLIC')) !== false;
throw new RepositoryException($name . '配置错误' . ($isPublic ? '(当前内容是公钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置');
}
return $pem;
}
/**
* 读取网关配置:后台站点配置优先,为空回退 config/services.phpenv
*/
protected function config(string $key): string
{
$value = site_config('wangpu.' . $key, '');
if ($value === null || $value === '') {
$value = config('services.wangpu.' . $key, '');
}
return trim((string) $value);
}
}