feat: add vip payment and member center

This commit is contained in:
2026-04-11 12:01:52 +08:00
parent db26820544
commit 746116d325
23 changed files with 1781 additions and 2 deletions
+207
View File
@@ -0,0 +1,207 @@
<?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;
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, '.', ''),
'channel' => 'web',
'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), '/');
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* 文件功能:VIP 支付业务服务
* 负责本地 VIP 订单创建、同步远端支付单、处理回调并在支付成功后开通会员
*/
namespace App\Services;
use App\Models\User;
use App\Models\VipLevel;
use App\Models\VipPaymentOrder;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class VipPaymentService
{
/**
* 构造函数
*
* @param VipPaymentCenterClient $paymentCenterClient 支付中心客户端
* @param VipService $vipService VIP 开通服务
*/
public function __construct(
private readonly VipPaymentCenterClient $paymentCenterClient,
private readonly VipService $vipService,
) {}
/**
* 创建本地 VIP 支付订单
*
* @param User $user 购买用户
* @param VipLevel $vipLevel 目标 VIP 等级
*/
public function createLocalOrder(User $user, VipLevel $vipLevel): VipPaymentOrder
{
if ((float) $vipLevel->price <= 0) {
throw new RuntimeException('当前 VIP 等级未设置在线支付价格,暂不支持直接购买。');
}
return VipPaymentOrder::create([
'order_no' => $this->generateOrderNo(),
'merchant_order_no' => $this->generateOrderNo(),
'user_id' => $user->id,
'vip_level_id' => $vipLevel->id,
'status' => 'created',
'amount' => $vipLevel->price,
'subject' => '购买 VIP 会员 - '.$vipLevel->name,
'provider' => 'alipay',
'vip_name' => $vipLevel->name,
'vip_duration_days' => (int) $vipLevel->duration_days,
'meta' => [
'username' => $user->username,
],
]);
}
/**
* 调用支付中心创建远端支付单
*
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
* @return array<string, mixed>
*/
public function createRemoteOrder(VipPaymentOrder $vipPaymentOrder): array
{
$remoteOrder = $this->paymentCenterClient->createOrder(
$vipPaymentOrder,
route('vip.payment.return'),
route('vip.payment.notify')
);
// 将远端平台支付单号回填到本地订单,后续回调和补单都依赖它。
$vipPaymentOrder->update([
'payment_order_no' => (string) ($remoteOrder['payment_order_no'] ?? ''),
'status' => 'pending',
]);
return $remoteOrder;
}
/**
* 根据远端支付单号查找本地订单
*
* @param string|null $paymentOrderNo 远端平台支付单号
*/
public function findByPaymentOrderNo(?string $paymentOrderNo): ?VipPaymentOrder
{
if (! $paymentOrderNo) {
return null;
}
return VipPaymentOrder::query()->where('payment_order_no', $paymentOrderNo)->first();
}
/**
* 根据本地业务订单号查找本地订单
*
* @param string|null $merchantOrderNo 商户业务订单号
*/
public function findByMerchantOrderNo(?string $merchantOrderNo): ?VipPaymentOrder
{
if (! $merchantOrderNo) {
return null;
}
return VipPaymentOrder::query()->where('merchant_order_no', $merchantOrderNo)->first();
}
/**
* 在同步回调时主动补查远端状态
*
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
*/
public function syncRemoteStatus(VipPaymentOrder $vipPaymentOrder): VipPaymentOrder
{
if (! $vipPaymentOrder->payment_order_no || $vipPaymentOrder->isVipOpened()) {
return $vipPaymentOrder;
}
$remoteOrder = $this->paymentCenterClient->queryOrder($vipPaymentOrder->payment_order_no);
if (($remoteOrder['status'] ?? null) === 'paid') {
return $this->markOrderAsPaid($vipPaymentOrder, $remoteOrder, 'sync');
}
if (($remoteOrder['status'] ?? null) === 'closed') {
$vipPaymentOrder->update(['status' => 'closed']);
}
return $vipPaymentOrder->fresh();
}
/**
* 记录同步回调原始参数
*
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
* @param array<string, mixed> $payload 同步回调参数
*/
public function recordSyncReturn(VipPaymentOrder $vipPaymentOrder, array $payload): void
{
$vipPaymentOrder->update([
'sync_return_payload' => $payload,
]);
}
/**
* 根据异步通知将订单标记为已支付,并完成会员开通
*
* @param VipPaymentOrder $vipPaymentOrder 本地 VIP 支付订单
* @param array<string, mixed> $payload 支付中心回调数据
* @param string $source 触发来源:sync|async
*/
public function markOrderAsPaid(VipPaymentOrder $vipPaymentOrder, array $payload, string $source = 'async'): VipPaymentOrder
{
return DB::transaction(function () use ($vipPaymentOrder, $payload, $source) {
$lockedOrder = VipPaymentOrder::query()
->with(['user', 'vipLevel'])
->lockForUpdate()
->findOrFail($vipPaymentOrder->id);
$amount = number_format((float) $lockedOrder->amount, 2, '.', '');
$callbackAmount = number_format((float) ($payload['amount'] ?? 0), 2, '.', '');
if ($amount !== $callbackAmount) {
throw new RuntimeException('支付金额校验失败,已拒绝开通会员。');
}
// 无论是同步还是异步,都保留原始回调数据,方便后台排查问题。
$lockedOrder->fill([
'payment_order_no' => (string) ($payload['payment_order_no'] ?? $lockedOrder->payment_order_no),
'provider' => (string) ($payload['provider'] ?? $lockedOrder->provider ?? 'alipay'),
'provider_trade_no' => (string) ($payload['provider_trade_no'] ?? $lockedOrder->provider_trade_no),
'status' => 'paid',
'paid_at' => isset($payload['paid_at']) ? Carbon::parse((string) $payload['paid_at']) : ($lockedOrder->paid_at ?? now()),
]);
if ($source === 'async') {
$lockedOrder->async_notify_payload = $payload;
}
if ($source === 'sync') {
$lockedOrder->sync_return_payload = array_merge($lockedOrder->sync_return_payload ?? [], $payload);
}
$lockedOrder->save();
if (! $lockedOrder->isVipOpened()) {
// 只在首次成功支付时开通会员,防止重复回调导致会员时长重复叠加。
$user = User::query()->lockForUpdate()->findOrFail($lockedOrder->user_id);
$this->vipService->grantVip($user, $lockedOrder->vip_level_id, (int) $lockedOrder->vip_duration_days);
$lockedOrder->update([
'opened_vip_at' => now(),
]);
}
return $lockedOrder->fresh(['user', 'vipLevel']);
});
}
/**
* 生成本地 VIP 订单号
*/
private function generateOrderNo(): string
{
return 'VPO'.date('YmdHis').random_int(1000, 9999);
}
}