319 lines
12 KiB
PHP
319 lines
12 KiB
PHP
<?php
|
|
|
|
/**
|
|
* 文件功能:VIP 支付业务服务
|
|
* 负责本地 VIP 订单创建、同步远端支付单、处理回调并在支付成功后开通会员
|
|
*/
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Events\EffectBroadcast;
|
|
use App\Events\MessageSent;
|
|
use App\Models\User;
|
|
use App\Models\VipLevel;
|
|
use App\Models\VipPaymentOrder;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* VIP 支付业务服务
|
|
* 负责支付订单创建、支付回调幂等处理,以及会员开通成功后的聊天室喜报广播。
|
|
*/
|
|
class VipPaymentService
|
|
{
|
|
/**
|
|
* 构造函数
|
|
*
|
|
* @param VipPaymentCenterClient $paymentCenterClient 支付中心客户端
|
|
* @param VipService $vipService VIP 开通服务
|
|
*/
|
|
public function __construct(
|
|
private readonly VipPaymentCenterClient $paymentCenterClient,
|
|
private readonly VipService $vipService,
|
|
private readonly ChatStateService $chatState,
|
|
private readonly RoomBroadcastService $roomBroadcastService,
|
|
) {}
|
|
|
|
/**
|
|
* 创建本地 VIP 支付订单
|
|
*
|
|
* @param User $user 购买用户
|
|
* @param VipLevel $vipLevel 目标 VIP 等级
|
|
* @param string $provider 用户选择的支付渠道
|
|
*/
|
|
public function createLocalOrder(User $user, VipLevel $vipLevel, string $provider): VipPaymentOrder
|
|
{
|
|
$currentVip = $user->isVip() ? $user->vipLevel : null;
|
|
$isUpgrade = $currentVip && $vipLevel->isHigherThan($currentVip);
|
|
$provider = $this->normalizeProvider($provider);
|
|
|
|
// 如果已经是该等级或更高级别,且不是永久会员续费(逻辑上续费应该用原价,但此处 user 需求是升级补差价)
|
|
// 这里我们主要处理补差价升级逻辑。
|
|
$price = $isUpgrade
|
|
? $vipLevel->getUpgradePrice($currentVip)
|
|
: (float) $vipLevel->price;
|
|
|
|
if ($price < 0.01) {
|
|
// 如果差价极小或为 0(例如同级或降级),抛出异常或根据业务逻辑处理
|
|
if ($isUpgrade) {
|
|
throw new RuntimeException('当前等级差价不足 0.01 元,无法发起升级。');
|
|
}
|
|
if ($user->vip_level_id === $vipLevel->id) {
|
|
// 续费逻辑保持原价
|
|
$price = (float) $vipLevel->price;
|
|
} else {
|
|
throw new RuntimeException('不支持降级购买会员。');
|
|
}
|
|
}
|
|
|
|
return VipPaymentOrder::create([
|
|
'order_no' => $this->generateOrderNo(),
|
|
'merchant_order_no' => $this->generateOrderNo(),
|
|
'user_id' => $user->id,
|
|
'vip_level_id' => $vipLevel->id,
|
|
'status' => 'created',
|
|
'amount' => $price,
|
|
'subject' => ($isUpgrade ? '【升级】' : '购买').' VIP 会员 - '.$vipLevel->name,
|
|
// 下单时必须固化用户选择的支付渠道,避免支付中心拒绝未指定 provider 的请求。
|
|
'provider' => $provider,
|
|
'vip_name' => $vipLevel->name,
|
|
'vip_duration_days' => (int) $vipLevel->duration_days,
|
|
'meta' => [
|
|
'username' => $user->username,
|
|
'is_upgrade' => $isUpgrade,
|
|
'old_vip_level_id' => $currentVip?->id,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 调用支付中心创建远端支付单
|
|
*
|
|
* @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
|
|
{
|
|
$shouldBroadcastVipCelebration = false;
|
|
|
|
$paidOrder = DB::transaction(function () use ($vipPaymentOrder, $payload, $source, &$shouldBroadcastVipCelebration) {
|
|
$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);
|
|
|
|
// 从订单扩展信息中识别是否为升级购买,保证会员时长与等级处理一致。
|
|
$isUpgrade = (bool) ($lockedOrder->meta['is_upgrade'] ?? false);
|
|
|
|
$this->vipService->grantVip($user, $lockedOrder->vip_level_id, (int) $lockedOrder->vip_duration_days, $isUpgrade);
|
|
|
|
// 仅首次开通时触发聊天室喜报,重复回调只更新订单状态不重复刷屏。
|
|
$lockedOrder->update([
|
|
'opened_vip_at' => now(),
|
|
]);
|
|
|
|
$shouldBroadcastVipCelebration = true;
|
|
}
|
|
|
|
return $lockedOrder->fresh(['user', 'vipLevel']);
|
|
});
|
|
|
|
if ($shouldBroadcastVipCelebration) {
|
|
$this->broadcastVipPurchaseCelebration($paidOrder);
|
|
}
|
|
|
|
return $paidOrder;
|
|
}
|
|
|
|
/**
|
|
* 生成本地 VIP 订单号
|
|
*/
|
|
private function generateOrderNo(): string
|
|
{
|
|
return 'VPO'.date('YmdHis').random_int(1000, 9999);
|
|
}
|
|
|
|
/**
|
|
* 规范化并校验支付渠道
|
|
*
|
|
* @param string $provider 前端提交的支付渠道
|
|
*/
|
|
private function normalizeProvider(string $provider): string
|
|
{
|
|
$provider = trim(strtolower($provider));
|
|
|
|
if (! in_array($provider, ['alipay', 'wechat'], true)) {
|
|
throw new RuntimeException('当前支付方式不受支持。');
|
|
}
|
|
|
|
return $provider;
|
|
}
|
|
|
|
/**
|
|
* 向用户当前在线的聊天室广播 VIP 购买成功喜报与烟花特效。
|
|
*
|
|
* @param VipPaymentOrder $vipPaymentOrder 已完成开通的支付订单
|
|
*/
|
|
private function broadcastVipPurchaseCelebration(VipPaymentOrder $vipPaymentOrder): void
|
|
{
|
|
$user = User::query()->with('vipLevel')->find($vipPaymentOrder->user_id);
|
|
|
|
if (! $user) {
|
|
return;
|
|
}
|
|
|
|
$purchasePayload = $this->roomBroadcastService->buildVipPurchasePayload($user);
|
|
if (empty($purchasePayload)) {
|
|
return;
|
|
}
|
|
|
|
$roomIds = $this->chatState->getUserRooms($user->username);
|
|
foreach ($roomIds as $roomId) {
|
|
// 先把喜报写入房间历史,确保当前在线用户和后续短时间内进房的人都能看到。
|
|
$celebrationMessage = [
|
|
'id' => $this->chatState->nextMessageId($roomId),
|
|
'room_id' => $roomId,
|
|
'from_user' => '会员播报',
|
|
'to_user' => '大家',
|
|
'content' => sprintf(
|
|
'<span style="color: %s; font-weight: bold;">%s</span>',
|
|
$purchasePayload['presence_color'] ?: '#f59e0b',
|
|
$purchasePayload['presence_text']
|
|
),
|
|
'is_secret' => false,
|
|
'font_color' => $purchasePayload['presence_color'] ?: '#f59e0b',
|
|
'action' => 'vip_presence',
|
|
'sent_at' => now()->toDateTimeString(),
|
|
];
|
|
|
|
$celebrationMessage = array_merge($celebrationMessage, $purchasePayload);
|
|
|
|
$this->chatState->pushMessage($roomId, $celebrationMessage);
|
|
broadcast(new MessageSent($roomId, $celebrationMessage));
|
|
|
|
// 购买成功固定播放烟花,和会员登录时的豪华表现保持一致。
|
|
broadcast(new EffectBroadcast($roomId, 'fireworks', $user->username));
|
|
}
|
|
}
|
|
}
|