From dca43a2d0d2db80f6685c4f8335f0480a5721356 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 12 Apr 2026 23:25:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96vip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/VipCenterController.php | 150 ++++++++++-------- .../Requests/CreateVipPaymentOrderRequest.php | 30 ++++ app/Services/RoomBroadcastService.php | 33 ++++ app/Services/VipPaymentService.php | 76 ++++++++- app/Services/VipPresenceService.php | 43 +++++ .../chat/partials/layout/toolbar.blade.php | 50 +++++- .../views/chat/partials/scripts.blade.php | 9 +- resources/views/vip/center.blade.php | 2 +- .../Feature/VipPaymentIntegrationTest.php | 53 ++++--- 9 files changed, 346 insertions(+), 100 deletions(-) diff --git a/app/Http/Controllers/VipCenterController.php b/app/Http/Controllers/VipCenterController.php index c52e4b6..c38f449 100644 --- a/app/Http/Controllers/VipCenterController.php +++ b/app/Http/Controllers/VipCenterController.php @@ -47,79 +47,101 @@ class VipCenterController extends Controller ->where('status', 'paid') ->sum('amount'); - $data = [ - 'user' => [ - 'id' => $user->id, - 'username' => $user->username, - 'is_vip' => $user->isVip(), - 'vip_name' => $user->vipName(), - 'hy_time' => $user->hy_time?->format('Y-m-d H:i'), - 'vip_level_id' => $user->vip_level_id, - 'can_customize' => $user->canCustomizeVipPresence(), - 'custom_join_message' => $user->custom_join_message, - 'custom_leave_message' => $user->custom_leave_message, - 'custom_join_effect' => $user->custom_join_effect, - 'custom_leave_effect' => $user->custom_leave_effect, - 'vip_level' => $user->vipLevel ? [ - 'id' => $user->vipLevel->id, - 'name' => $user->vipLevel->name, - 'icon' => $user->vipLevel->icon, - 'color' => $user->vipLevel->color, - 'join_effect' => $user->vipLevel->joinEffectKey(), - 'join_banner' => $user->vipLevel->joinBannerStyleKey(), - 'leave_effect' => $user->vipLevel->leaveEffectKey(), - 'leave_banner' => $user->vipLevel->leaveBannerStyleKey(), - 'join_templates' => $user->vipLevel->join_templates_array, - 'leave_templates' => $user->vipLevel->leave_templates_array, - ] : null, - ], - 'vipLevels' => $vipLevels->map(function ($vip) { - return [ - 'id' => $vip->id, - 'name' => $vip->name, - 'icon' => $vip->icon, - 'color' => $vip->color, - 'price' => $vip->price, - 'duration_days' => $vip->duration_days, - 'exp_multiplier' => $vip->exp_multiplier, - 'jjb_multiplier' => $vip->jjb_multiplier, - 'description' => $vip->description, - ]; - }), - 'paymentLogs' => $paymentLogs->items(), - 'vipPaymentEnabled' => Sysparam::getValue('vip_payment_enabled', '0') === '1', - 'paidOrders' => $paidOrders, - 'totalAmount' => $totalAmount, - 'effectOptions' => [ - 'none' => '无特效', - 'fireworks' => '烟花', - 'rain' => '下雨', - 'lightning' => '闪电', - 'snow' => '下雪', - 'sakura' => '樱花飘落', - 'meteors' => '流星', - 'gold-rain' => '金币雨', - 'hearts' => '爱心飘落', - 'confetti' => '彩带庆典', - 'fireflies' => '萤火虫', - ], - 'bannerStyleOptions' => [ - 'aurora' => '鎏光星幕', - 'storm' => '雷霆风暴', - 'royal' => '王者金辉', - 'cosmic' => '星穹幻彩', - 'farewell' => '告别暮光', - ], + $vipPaymentEnabled = Sysparam::getValue('vip_payment_enabled', '0') === '1'; + + $effectOptions = [ + 'none' => '无特效', + 'fireworks' => '烟花', + 'rain' => '下雨', + 'lightning' => '闪电', + 'snow' => '下雪', + 'sakura' => '樱花飘落', + 'meteors' => '流星', + 'gold-rain' => '金币雨', + 'hearts' => '爱心飘落', + 'confetti' => '彩带庆典', + 'fireflies' => '萤火虫', + ]; + + $bannerStyleOptions = [ + 'aurora' => '鎏光星幕', + 'storm' => '雷霆风暴', + 'royal' => '王者金辉', + 'cosmic' => '星穹幻彩', + 'farewell' => '告别暮光', ]; if ($request->expectsJson()) { + $data = [ + 'user' => [ + 'id' => $user->id, + 'username' => $user->username, + 'is_vip' => $user->isVip(), + 'vip_name' => $user->vipName(), + 'hy_time' => $user->hy_time?->format('Y-m-d H:i'), + 'vip_level_id' => $user->vip_level_id, + 'can_customize' => $user->canCustomizeVipPresence(), + 'custom_join_message' => $user->custom_join_message, + 'custom_leave_message' => $user->custom_leave_message, + 'custom_join_effect' => $user->custom_join_effect, + 'custom_leave_effect' => $user->custom_leave_effect, + 'vip_level' => $user->vipLevel ? [ + 'id' => $user->vipLevel->id, + 'name' => $user->vipLevel->name, + 'icon' => $user->vipLevel->icon, + 'color' => $user->vipLevel->color, + 'join_effect' => $user->vipLevel->joinEffectKey(), + 'join_banner' => $user->vipLevel->joinBannerStyleKey(), + 'leave_effect' => $user->vipLevel->leaveEffectKey(), + 'leave_banner' => $user->vipLevel->leaveBannerStyleKey(), + 'join_templates' => $user->vipLevel->join_templates_array, + 'leave_templates' => $user->vipLevel->leave_templates_array, + ] : null, + ], + 'vipLevels' => $vipLevels->map(function ($vip) use ($user) { + $isCurrent = $user->isVip() && (int) $user->vip_level_id === (int) $vip->id; + $isHigher = $user->isVip() ? $vip->isHigherThan($user->vip_level_id) : true; + $isLower = $user->isVip() && ! $isCurrent && ! $isHigher; + + return [ + 'id' => $vip->id, + 'name' => $vip->name, + 'icon' => $vip->icon, + 'color' => $vip->color, + 'price' => (float) $vip->price, + 'upgrade_price' => $user->isVip() ? (float) $vip->getUpgradePrice($user->vip_level_id) : (float) $vip->price, + 'duration_days' => $vip->duration_days, + 'exp_multiplier' => $vip->exp_multiplier, + 'jjb_multiplier' => $vip->jjb_multiplier, + 'description' => $vip->description, + 'is_current' => $isCurrent, + 'is_higher' => $isHigher, + 'is_lower' => $isLower, + ]; + }), 'paymentLogs' => $paymentLogs->items(), + 'vipPaymentEnabled' => $vipPaymentEnabled, + 'paidOrders' => $paidOrders, + 'totalAmount' => $totalAmount, + 'effectOptions' => $effectOptions, + 'bannerStyleOptions' => $bannerStyleOptions, + ]; + return response()->json([ 'status' => 'success', 'data' => $data, ]); } - return view('vip.center', $data); + return view('vip.center', [ + 'user' => $user, + 'vipLevels' => $vipLevels, + 'paymentLogs' => $paymentLogs, + 'vipPaymentEnabled' => $vipPaymentEnabled, + 'paidOrders' => $paidOrders, + 'totalAmount' => $totalAmount, + 'effectOptions' => $effectOptions, + 'bannerStyleOptions' => $bannerStyleOptions, + ]); } /** diff --git a/app/Http/Requests/CreateVipPaymentOrderRequest.php b/app/Http/Requests/CreateVipPaymentOrderRequest.php index a43c23d..a57bf20 100644 --- a/app/Http/Requests/CreateVipPaymentOrderRequest.php +++ b/app/Http/Requests/CreateVipPaymentOrderRequest.php @@ -31,6 +31,36 @@ class CreateVipPaymentOrderRequest extends FormRequest ]; } + /** + * 配置验证器实例。 + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $user = $this->user(); + if (! $user || ! $user->isVip()) { + return; + } + + $vipLevelId = (int) $this->vip_level_id; + $targetLevel = \App\Models\VipLevel::find($vipLevelId); + + if (! $targetLevel) { + return; + } + + $currentLevelId = (int) $user->vip_level_id; + + // 逻辑:允许续费当前等级,或购买更高等级。禁止降级。 + if ($vipLevelId !== $currentLevelId && ! $targetLevel->isHigherThan($currentLevelId)) { + $validator->errors()->add('vip_level_id', '当前仅支持续费同级会员或补差价升级到更高等级,暂不支持降级购买。'); + } + }); + } + /** * 获取中文错误提示 * diff --git a/app/Services/RoomBroadcastService.php b/app/Services/RoomBroadcastService.php index 7fd0452..a401d16 100644 --- a/app/Services/RoomBroadcastService.php +++ b/app/Services/RoomBroadcastService.php @@ -13,6 +13,10 @@ namespace App\Services; use App\Models\User; +/** + * 聊天室入场、离场与会员喜报播报服务 + * 负责统一生成进退场文本,以及会员主题横幅和购买成功喜报的前端载荷。 + */ class RoomBroadcastService { /** @@ -183,4 +187,33 @@ class RoomBroadcastService 'presence_icon' => (string) ($theme['icon'] ?? ''), ]; } + + /** + * 构建会员购买成功喜报的前端载荷。 + * + * @return array + */ + public function buildVipPurchasePayload(User $user): array + { + $theme = $this->vipPresenceService->buildPurchaseTheme($user); + + if (empty($theme['enabled'])) { + return []; + } + + $text = trim((string) ($theme['text'] ?? '')); + if ($text === '') { + return []; + } + + return [ + 'presence_type' => 'purchase', + 'presence_text' => $text, + 'presence_color' => (string) ($theme['color'] ?? ''), + 'presence_effect' => $theme['effect'] ? (string) $theme['effect'] : null, + 'presence_banner_style' => (string) ($theme['banner_style'] ?? ''), + 'presence_level_name' => (string) ($theme['level_name'] ?? ''), + 'presence_icon' => (string) ($theme['icon'] ?? ''), + ]; + } } diff --git a/app/Services/VipPaymentService.php b/app/Services/VipPaymentService.php index 9877b7e..6138505 100644 --- a/app/Services/VipPaymentService.php +++ b/app/Services/VipPaymentService.php @@ -7,6 +7,8 @@ namespace App\Services; +use App\Events\EffectBroadcast; +use App\Events\MessageSent; use App\Models\User; use App\Models\VipLevel; use App\Models\VipPaymentOrder; @@ -14,6 +16,10 @@ use Carbon\Carbon; use Illuminate\Support\Facades\DB; use RuntimeException; +/** + * VIP 支付业务服务 + * 负责支付订单创建、支付回调幂等处理,以及会员开通成功后的聊天室喜报广播。 + */ class VipPaymentService { /** @@ -25,6 +31,8 @@ class VipPaymentService public function __construct( private readonly VipPaymentCenterClient $paymentCenterClient, private readonly VipService $vipService, + private readonly ChatStateService $chatState, + private readonly RoomBroadcastService $roomBroadcastService, ) {} /** @@ -64,7 +72,7 @@ class VipPaymentService 'vip_level_id' => $vipLevel->id, 'status' => 'created', 'amount' => $price, - 'subject' => ($isUpgrade ? '【升级】' : '购买') . ' VIP 会员 - ' . $vipLevel->name, + 'subject' => ($isUpgrade ? '【升级】' : '购买').' VIP 会员 - '.$vipLevel->name, 'provider' => 'alipay', 'vip_name' => $vipLevel->name, 'vip_duration_days' => (int) $vipLevel->duration_days, @@ -173,7 +181,9 @@ class VipPaymentService */ public function markOrderAsPaid(VipPaymentOrder $vipPaymentOrder, array $payload, string $source = 'async'): VipPaymentOrder { - return DB::transaction(function () use ($vipPaymentOrder, $payload, $source) { + $shouldBroadcastVipCelebration = false; + + $paidOrder = DB::transaction(function () use ($vipPaymentOrder, $payload, $source, &$shouldBroadcastVipCelebration) { $lockedOrder = VipPaymentOrder::query() ->with(['user', 'vipLevel']) ->lockForUpdate() @@ -208,19 +218,28 @@ class VipPaymentService if (! $lockedOrder->isVipOpened()) { // 只在首次成功支付时开通会员,防止重复回调导致会员时长重复叠加。 $user = User::query()->lockForUpdate()->findOrFail($lockedOrder->user_id); - - // 从 meta 中提取是否是升级 + + // 从订单扩展信息中识别是否为升级购买,保证会员时长与等级处理一致。 $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; } /** @@ -230,4 +249,51 @@ class VipPaymentService { return 'VPO'.date('YmdHis').random_int(1000, 9999); } + + /** + * 向用户当前在线的聊天室广播 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( + '%s', + $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)); + } + } } diff --git a/app/Services/VipPresenceService.php b/app/Services/VipPresenceService.php index ac4ffcd..b2ee7c8 100644 --- a/app/Services/VipPresenceService.php +++ b/app/Services/VipPresenceService.php @@ -10,6 +10,10 @@ namespace App\Services; use App\Models\User; +/** + * 会员进退场与购买成功主题服务 + * 统一生成会员欢迎横幅、离场横幅与购买成功喜报所需的文本、颜色、特效和样式数据。 + */ class VipPresenceService { /** @@ -39,6 +43,45 @@ class VipPresenceService return $this->buildTheme($user, 'leave'); } + /** + * 构建会员购买成功主题数据。 + * + * @return array + */ + public function buildPurchaseTheme(User $user): array + { + $vipLevel = $user->vipLevel; + + if (! $user->isVip() || ! $vipLevel) { + return [ + 'enabled' => false, + 'type' => 'purchase', + 'text' => null, + 'color' => null, + 'effect' => null, + 'banner_style' => null, + 'level_name' => null, + 'icon' => null, + ]; + } + + return [ + 'enabled' => true, + 'type' => 'purchase', + 'text' => sprintf( + '恭喜 %s 成功开通【%s %s】,尊享 VIP 特权,大家掌声欢迎!', + $user->username, + $vipLevel->icon ?: '👑', + $vipLevel->name + ), + 'color' => $vipLevel->color ?: '#f59e0b', + 'effect' => 'fireworks', + 'banner_style' => $vipLevel->joinBannerStyleKey(), + 'level_name' => $vipLevel->name, + 'icon' => $vipLevel->icon, + ]; + } + /** * 统一构建会员进场或离场的主题数据。 * diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index a6e3f58..1e0d381 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -1648,7 +1648,34 @@ async function generateWechatBindCode() { // 会员等级 const grid = document.getElementById('vip-levels-grid'); grid.innerHTML = d.vipLevels.map(v => { - const isCurrent = d.user.vip_level_id === v.id && d.user.is_vip; + const isCurrent = v.is_current; + const isHigher = v.is_higher; + const isLower = v.is_lower; + + let btnText = '立即购买'; + let btnColor = '#1e293b'; + let btnTextColor = '#fff'; + let priceToDisplay = v.price; + let isDisabled = !d.vipPaymentEnabled; + let showUpgradeInfo = false; + + if (isCurrent) { + btnText = '立即续费'; + btnColor = '#f59e0b'; + btnTextColor = '#fff'; + } else if (isHigher && d.user.is_vip) { + btnText = '补差价升级'; + btnColor = '#4f46e5'; + btnTextColor = '#fff'; + priceToDisplay = v.upgrade_price; + showUpgradeInfo = true; + } else if (isLower) { + btnText = '无法降级'; + btnColor = '#f1f5f9'; + btnTextColor = '#94a3b8'; + isDisabled = true; + } + return `
${isCurrent ? '当前档位' : ''} @@ -1663,11 +1690,15 @@ async function generateWechatBindCode() {
专属入场特效 & 横幅
-
¥${Number(v.price).toFixed(2)} / ${v.duration_days}天
-
+ ${showUpgradeInfo ? `
已省 ¥${(v.price - v.upgrade_price).toFixed(2)}
` : ''} + @@ -1769,9 +1800,11 @@ async function generateWechatBindCode() { window.buyVip = function(levelId) { // 这里我们模拟提交表单,因为支付逻辑通常需要页面跳转 + // 修改为在新窗口打开支付,避免聊天室页面丢失 const form = document.createElement('form'); form.method = 'POST'; form.action = '{{ route('vip.payment.store') }}'; + form.target = '_blank'; // 新窗口打开支付 const csrfInput = document.createElement('input'); csrfInput.type = 'hidden'; @@ -1787,6 +1820,13 @@ async function generateWechatBindCode() { document.body.appendChild(form); form.submit(); + document.body.removeChild(form); + + // 提交后关闭弹窗并提示用户 + closeVipModal(); + if (window.chatDialog) { + window.chatDialog.alert('正在为您前往支付中心,请在新页面完成支付。', '支付提示', '#3b82f6'); + } }; window.saveVipPresenceSettings = async function() { diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 1b676fa..caebfd6 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -132,6 +132,9 @@ } const styleConfig = getVipPresenceStyleConfig(payload.presence_banner_style, payload.presence_color); + const bannerTypeLabel = payload.presence_type === 'leave' + ? '离场提示' + : (payload.presence_type === 'purchase' ? '开通喜报' : '闪耀登场'); const banner = document.createElement('div'); banner.id = 'vip-presence-banner'; banner.className = 'vip-presence-banner'; @@ -141,7 +144,7 @@
${escapeHtml(payload.presence_icon || '👑')} ${escapeHtml(payload.presence_level_name || '尊贵会员')} - ${payload.presence_type === 'leave' ? '离场提示' : '闪耀登场'} + ${bannerTypeLabel}
${escapePresenceText(payload.presence_text)}
@@ -783,7 +786,9 @@ const icon = escapeHtml(msg.presence_icon || '👑'); const levelName = escapeHtml(msg.presence_level_name || '尊贵会员'); - const typeLabel = msg.presence_type === 'leave' ? '华丽离场' : '荣耀入场'; + const typeLabel = msg.presence_type === 'leave' + ? '华丽离场' + : (msg.presence_type === 'purchase' ? '荣耀开通' : '荣耀入场'); const safeText = escapePresenceText(msg.presence_text || ''); html = ` diff --git a/resources/views/vip/center.blade.php b/resources/views/vip/center.blade.php index 736bb10..441eb30 100644 --- a/resources/views/vip/center.blade.php +++ b/resources/views/vip/center.blade.php @@ -165,7 +165,7 @@ @if ($vipPaymentEnabled) -
+ @csrf