支付优化
This commit is contained in:
@@ -97,19 +97,20 @@ class OnlinePaymentController extends BaseMiniController
|
||||
}
|
||||
|
||||
/**
|
||||
* 旺铺支付结果后台通知(公开路由,验签后幂等结账)
|
||||
* 旺铺支付结果后台通知(公开路由,解密信封后幂等结账)
|
||||
*
|
||||
* 网关约定:应答 {"code":"00"} 视为通知成功,否则按 2^n 分钟重试 7 次;
|
||||
* 通知报文与网关应答同为加密信封(data + signature,商户私钥可解),
|
||||
* 解密成功即视为合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次;
|
||||
* 重复通知必须幂等(settle 内部行锁 + 状态判断)。
|
||||
*/
|
||||
#[PostRoute('/payment/notify', authorize: false)]
|
||||
public function notify(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->all();
|
||||
|
||||
if (! $this->wangpu->verifyNotifySign($params)) {
|
||||
Log::warning('旺铺支付通知验签失败', ['params' => $params]);
|
||||
return $this->notifyAck('01', '验签失败');
|
||||
try {
|
||||
$params = $this->wangpu->decryptNotify($request->all());
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('旺铺支付通知解密失败', ['error' => $e->getMessage()]);
|
||||
return $this->notifyAck('01', '报文解密失败');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,13 +5,16 @@ namespace App\Services;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 旺铺支付网关服务(统一下单B-JSAPI / 交易查询 / 后台通知验签)
|
||||
* 旺铺支付网关服务(行业版接口:统一下单 / 交易查询 / 后台通知解密)
|
||||
*
|
||||
* 加签规则(请求与回调一致):
|
||||
* 除 sign 外所有非空参数按参数名 ASCII 码升序排列,以 key=value 形式用 & 拼接成 Str,
|
||||
* 在 Str 后拼接 &key={SignKey} 得到 SignStr,对其做 MD5(utf-8)并转大写即为签名值。
|
||||
* 通讯协议(与官方示例 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 得业务报文。
|
||||
*
|
||||
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.php(env)
|
||||
*/
|
||||
@@ -27,18 +30,20 @@ class WangpuPayService
|
||||
public const int ORDER_STATUS_PAID = 1;
|
||||
|
||||
/**
|
||||
* 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码)
|
||||
* 统一下单(小程序 JSAPI)
|
||||
*
|
||||
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
|
||||
* @return array<string, mixed> 网关 data 节点(含 order_id/trade_no/调起支付参数)
|
||||
* @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));
|
||||
}
|
||||
|
||||
@@ -47,12 +52,15 @@ class WangpuPayService
|
||||
*
|
||||
* @param string $merOrderId 商户唯一订单号(本系统 payment_no)
|
||||
* @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准)
|
||||
* @return array<string, mixed> 网关 data 节点
|
||||
* @return array<string, mixed> 网关 data 解密后的业务报文
|
||||
* @throws RepositoryException 网关应答失败
|
||||
*/
|
||||
public function queryOrder(string $merOrderId, string $orderId = ''): array
|
||||
{
|
||||
$params = ['mer_order_id' => $merOrderId];
|
||||
$params = [
|
||||
'organiz_no' => $this->config('organiz_no'),
|
||||
'mer_order_id' => $merOrderId,
|
||||
];
|
||||
if ($orderId !== '') {
|
||||
$params['order_id'] = $orderId;
|
||||
}
|
||||
@@ -60,44 +68,18 @@ class WangpuPayService
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验后台通知签名(支付成功/退款成功通知通用)
|
||||
* 解密支付结果后台通知(报文 RSA+AES 双重加密,解密成功即视为合法通知)
|
||||
*
|
||||
* @param array<string, mixed> $params 通知报文全量参数(含 sign)
|
||||
* @param array<string, mixed> $envelope 通知信封(含 data/signature)
|
||||
* @return array<string, mixed> 解密后的业务报文(mer_order_id/order_status/order_amt/trade_no 等)
|
||||
* @throws RepositoryException 报文解密失败
|
||||
*/
|
||||
public function verifyNotifySign(array $params): bool
|
||||
public function decryptNotify(array $envelope): array
|
||||
{
|
||||
$sign = (string) ($params['sign'] ?? '');
|
||||
if ($sign === '') {
|
||||
return false;
|
||||
}
|
||||
return hash_equals($this->sign($params), strtoupper($sign));
|
||||
}
|
||||
|
||||
/**
|
||||
* MD5 加签:非空参数(sign 除外)按参数名 ASCII 升序 key=value 以 & 拼接,
|
||||
* 末尾拼接 &key={SignKey} 后 MD5 转大写
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function sign(array $params): string
|
||||
{
|
||||
unset($params['sign']);
|
||||
|
||||
$pairs = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
$pairs[(string) $key] = (string) $key . '=' . (string) $value;
|
||||
}
|
||||
ksort($pairs, SORT_STRING);
|
||||
|
||||
$signStr = implode('&', $pairs) . '&key=' . $this->config('sign_key');
|
||||
|
||||
return strtoupper(md5($signStr));
|
||||
Log::driver('pay')->info('旺铺支付通知接收', ['envelope' => $envelope]);
|
||||
$params = $this->decryptEnvelope($envelope);
|
||||
Log::driver('pay')->info('旺铺支付通知解密', ['params' => $params]);
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +91,11 @@ class WangpuPayService
|
||||
}
|
||||
|
||||
/**
|
||||
* 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码
|
||||
* 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答
|
||||
*
|
||||
* @param array<string, mixed> $params 接口业务参数
|
||||
* @return array<string, mixed> 应答 data 节点
|
||||
* @throws RepositoryException 通讯失败或应答码非成功
|
||||
* @return array<string, mixed> 应答 data 解密后的业务报文
|
||||
* @throws RepositoryException 通讯失败 / 应答码非成功 / 应答解密失败
|
||||
*/
|
||||
protected function request(string $path, array $params): array
|
||||
{
|
||||
@@ -122,24 +104,18 @@ class WangpuPayService
|
||||
throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员');
|
||||
}
|
||||
|
||||
$body = array_merge([
|
||||
'organiz_no' => $this->config('organiz_no'),
|
||||
'mer_no' => $this->config('mer_no'),
|
||||
], $params);
|
||||
// 空值参数不下送(与网关签名口径一致)
|
||||
$body = array_filter($body, static fn ($value): bool => $value !== null && $value !== '');
|
||||
$body['sign'] = $this->sign($body);
|
||||
$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()]);
|
||||
|
||||
$response = Http::asForm()->withOptions([
|
||||
'verify' => false, // 禁用 SSL 证书验证
|
||||
])->timeout(15)->post($baseUrl . $path, $body);
|
||||
$result = $response->json();
|
||||
if (! is_array($result)) {
|
||||
Log::driver('pay')->error('旺铺网关应答异常', [
|
||||
'path' => $path,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
'request' => $body,
|
||||
]);
|
||||
throw new RepositoryException('支付网关通讯异常,请稍后重试');
|
||||
}
|
||||
@@ -147,13 +123,155 @@ class WangpuPayService
|
||||
Log::driver('pay')->warning('旺铺网关应答失败', [
|
||||
'path' => $path,
|
||||
'response' => $result,
|
||||
'request' => $body,
|
||||
]);
|
||||
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
$data = $result['data'] ?? [];
|
||||
return is_array($data) ? $data : [];
|
||||
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') {
|
||||
// 公钥兼容 SPKI(PUBLIC KEY)与 PKCS#1(RSA 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user