160 lines
5.9 KiB
PHP
160 lines
5.9 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Exceptions\RepositoryException;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* 旺铺支付网关服务(统一下单B-JSAPI / 交易查询 / 后台通知验签)
|
||
*
|
||
* 加签规则(请求与回调一致):
|
||
* 除 sign 外所有非空参数按参数名 ASCII 码升序排列,以 key=value 形式用 & 拼接成 Str,
|
||
* 在 Str 后拼接 &key={SignKey} 得到 SignStr,对其做 MD5(utf-8)并转大写即为签名值。
|
||
*
|
||
* 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.php(env)
|
||
*/
|
||
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;
|
||
|
||
/**
|
||
* 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码)
|
||
*
|
||
* @param array<string, mixed> $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等)
|
||
* @return array<string, mixed> 网关 data 节点(含 order_id/trade_no/调起支付参数)
|
||
* @throws RepositoryException 网关应答失败
|
||
*/
|
||
public function createOrder(array $params): array
|
||
{
|
||
return $this->request('/industrial/payment/order', array_merge([
|
||
'mer_code' => $this->config('mer_code'),
|
||
'term_code' => $this->config('term_code'),
|
||
'payway_code' => $this->config('payway_code'),
|
||
], $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 = ['mer_order_id' => $merOrderId];
|
||
if ($orderId !== '') {
|
||
$params['order_id'] = $orderId;
|
||
}
|
||
return $this->request('/industrial/query/order', $params);
|
||
}
|
||
|
||
/**
|
||
* 校验后台通知签名(支付成功/退款成功通知通用)
|
||
*
|
||
* @param array<string, mixed> $params 通知报文全量参数(含 sign)
|
||
*/
|
||
public function verifyNotifySign(array $params): bool
|
||
{
|
||
$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));
|
||
}
|
||
|
||
/**
|
||
* 支付结果后台通知地址(统一下单时上送的 notifyurl)
|
||
*/
|
||
public function notifyUrl(): string
|
||
{
|
||
return rtrim((string) config('app.url'), '/') . '/mini/payment/notify';
|
||
}
|
||
|
||
/**
|
||
* 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码
|
||
*
|
||
* @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('旺铺支付未配置网关地址,请联系管理员');
|
||
}
|
||
|
||
$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);
|
||
|
||
$response = Http::asForm()->timeout(15)->post($baseUrl . $path, $body);
|
||
$result = $response->json();
|
||
if (! is_array($result)) {
|
||
Log::error('旺铺网关应答异常', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]);
|
||
throw new RepositoryException('支付网关通讯异常,请稍后重试');
|
||
}
|
||
if (($result['code'] ?? '') !== self::CODE_SUCCESS) {
|
||
Log::warning('旺铺网关应答失败', ['path' => $path, 'response' => $result]);
|
||
throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误'));
|
||
}
|
||
|
||
$data = $result['data'] ?? [];
|
||
return is_array($data) ? $data : [];
|
||
}
|
||
|
||
/**
|
||
* 读取网关配置:后台站点配置优先,为空回退 config/services.php(env)
|
||
*/
|
||
protected function config(string $key): string
|
||
{
|
||
$value = site_config('pay.wangpu_' . $key, '');
|
||
if ($value === null || $value === '') {
|
||
$value = config('services.wangpu.' . $key, '');
|
||
}
|
||
return trim((string) $value);
|
||
}
|
||
}
|