213 lines
6.9 KiB
PHP
213 lines
6.9 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 文件功能:NovaLink 支付中心客户端
|
||
* 负责读取聊天室后台配置,按支付中心要求生成签名并发起下单、查单等请求
|
||
*/
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Sysparam;
|
||
use App\Models\VipPaymentOrder;
|
||
use Illuminate\Http\Client\Response;
|
||
use Illuminate\Support\Facades\Http;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* NovaLink 支付中心客户端
|
||
* 负责构建开放接口签名请求,并与远端支付中心完成下单和查单通信。
|
||
*/
|
||
class VipPaymentCenterClient
|
||
{
|
||
/**
|
||
* 判断 VIP 在线支付是否已启用
|
||
*/
|
||
public function isEnabled(): bool
|
||
{
|
||
return Sysparam::getValue('vip_payment_enabled', '0') === '1';
|
||
}
|
||
|
||
/**
|
||
* 判断支付中心接入配置是否完整
|
||
*/
|
||
public function hasValidConfig(): bool
|
||
{
|
||
$config = $this->getConfig();
|
||
|
||
return $config['base_url'] !== ''
|
||
&& $config['app_key'] !== ''
|
||
&& $config['app_secret'] !== '';
|
||
}
|
||
|
||
/**
|
||
* 获取当前支付中心配置
|
||
*
|
||
* @return array{base_url:string,app_key:string,app_secret:string,timeout:int}
|
||
*/
|
||
public function getConfig(): array
|
||
{
|
||
return [
|
||
'base_url' => $this->normalizeBaseUrl(Sysparam::getValue('vip_payment_base_url', '')),
|
||
'app_key' => Sysparam::getValue('vip_payment_app_key', ''),
|
||
'app_secret' => Sysparam::getValue('vip_payment_app_secret', ''),
|
||
'timeout' => (int) Sysparam::getValue('vip_payment_timeout', '10'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 为本地 VIP 订单在支付中心创建远端支付单
|
||
*
|
||
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
|
||
* @param string $syncReturnUrl 同步回调地址
|
||
* @param string $asyncNotifyUrl 异步回调地址
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function createOrder(VipPaymentOrder $vipPaymentOrder, string $syncReturnUrl, string $asyncNotifyUrl): array
|
||
{
|
||
$payload = [
|
||
'merchant_order_no' => $vipPaymentOrder->merchant_order_no,
|
||
'subject' => $vipPaymentOrder->subject,
|
||
'body' => '聊天室 VIP 会员购买:'.$vipPaymentOrder->vip_name,
|
||
'amount' => number_format((float) $vipPaymentOrder->amount, 2, '.', ''),
|
||
// 支付中心要求商户显式指定 provider,不能依赖平台自动推断。
|
||
'provider' => $vipPaymentOrder->provider,
|
||
'business_type' => 'chatroom_vip',
|
||
'sync_return_url' => $syncReturnUrl,
|
||
'async_notify_url' => $asyncNotifyUrl,
|
||
'meta' => [
|
||
'user_id' => $vipPaymentOrder->user_id,
|
||
'vip_level_id' => $vipPaymentOrder->vip_level_id,
|
||
'local_order_no' => $vipPaymentOrder->order_no,
|
||
],
|
||
];
|
||
|
||
return $this->request('POST', '/api/open/v1/pay/orders', $payload);
|
||
}
|
||
|
||
/**
|
||
* 查询支付中心订单状态
|
||
*
|
||
* @param string $paymentOrderNo 远端平台支付单号
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function queryOrder(string $paymentOrderNo): array
|
||
{
|
||
return $this->request('GET', '/api/open/v1/pay/orders/'.$paymentOrderNo);
|
||
}
|
||
|
||
/**
|
||
* 校验支付中心异步回调签名
|
||
*
|
||
* @param string $signature 请求头携带的签名
|
||
* @param string $rawBody 原始请求体 JSON
|
||
*/
|
||
public function isValidWebhookSignature(string $signature, string $rawBody): bool
|
||
{
|
||
$config = $this->getConfig();
|
||
|
||
if ($signature === '' || $config['app_secret'] === '') {
|
||
return false;
|
||
}
|
||
|
||
// 支付中心回调直接使用 JSON 原文做 HMAC,避免数组重组后签名不一致。
|
||
$expectedSignature = hash_hmac('sha256', $rawBody, $config['app_secret']);
|
||
|
||
return hash_equals($expectedSignature, $signature);
|
||
}
|
||
|
||
/**
|
||
* 调用支付中心开放接口
|
||
*
|
||
* @param string $method 请求方法
|
||
* @param string $path 接口路径
|
||
* @param array<string, mixed> $payload 请求体
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function request(string $method, string $path, array $payload = []): array
|
||
{
|
||
if (! $this->isEnabled() || ! $this->hasValidConfig()) {
|
||
throw new RuntimeException('VIP 支付中心尚未完成配置。');
|
||
}
|
||
|
||
$config = $this->getConfig();
|
||
$body = strtoupper($method) === 'GET'
|
||
? ''
|
||
: json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
|
||
// 先按 NovaLink 约定计算签名头,再发起请求。
|
||
$headers = $this->buildHeaders($method, $path, $body, $config['app_key'], $config['app_secret']);
|
||
$request = Http::timeout(max($config['timeout'], 3))
|
||
->acceptJson()
|
||
->withHeaders($headers);
|
||
|
||
$response = strtoupper($method) === 'GET'
|
||
? $request->get($config['base_url'].$path)
|
||
: $request->withBody($body, 'application/json')->send($method, $config['base_url'].$path);
|
||
|
||
return $this->parseResponse($response);
|
||
}
|
||
|
||
/**
|
||
* 构建开放 API 认证请求头
|
||
*
|
||
* @param string $method 请求方法
|
||
* @param string $path 接口路径
|
||
* @param string $body JSON 请求体
|
||
* @param string $appKey 接入应用 Key
|
||
* @param string $appSecret 接入应用 Secret
|
||
* @return array<string, string>
|
||
*/
|
||
private function buildHeaders(string $method, string $path, string $body, string $appKey, string $appSecret): array
|
||
{
|
||
$timestamp = (string) now()->timestamp;
|
||
$nonce = (string) str()->uuid();
|
||
|
||
// 保持与 NovaLink 中间件完全一致的签名原文拼接顺序。
|
||
$signString = implode("\n", [
|
||
strtoupper($method),
|
||
$path,
|
||
$timestamp,
|
||
$nonce,
|
||
hash('sha256', $body),
|
||
]);
|
||
|
||
return [
|
||
'X-App-Key' => $appKey,
|
||
'X-Timestamp' => $timestamp,
|
||
'X-Nonce' => $nonce,
|
||
'X-Signature' => hash_hmac('sha256', $signString, $appSecret),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 解析支付中心接口响应
|
||
*
|
||
* @param Response $response HTTP 响应对象
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function parseResponse(Response $response): array
|
||
{
|
||
if (! $response->successful()) {
|
||
throw new RuntimeException('支付中心请求失败:'.$response->body());
|
||
}
|
||
|
||
$json = $response->json();
|
||
|
||
if (! is_array($json) || ! ($json['success'] ?? false)) {
|
||
throw new RuntimeException((string) ($json['message'] ?? '支付中心返回了无效响应。'));
|
||
}
|
||
|
||
return (array) ($json['data'] ?? []);
|
||
}
|
||
|
||
/**
|
||
* 规范化支付中心地址
|
||
*
|
||
* @param string $baseUrl 后台录入的地址
|
||
*/
|
||
private function normalizeBaseUrl(string $baseUrl): string
|
||
{
|
||
return rtrim(trim($baseUrl), '/');
|
||
}
|
||
}
|