Files
chatroom/app/Services/VipPaymentService.php
2026-04-13 17:25:33 +08:00

319 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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));
}
}
}