优化vip

This commit is contained in:
2026-04-12 23:25:38 +08:00
parent 353aaaf6ce
commit dca43a2d0d
9 changed files with 346 additions and 100 deletions

View File

@@ -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,
]);
}
/**

View File

@@ -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', '当前仅支持续费同级会员或补差价升级到更高等级,暂不支持降级购买。');
}
});
}
/**
* 获取中文错误提示
*

View File

@@ -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<string, string|null>
*/
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'] ?? ''),
];
}
}

View File

@@ -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(
'<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));
}
}
}

View File

@@ -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<string, string|null|bool>
*/
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,
];
}
/**
* 统一构建会员进场或离场的主题数据。
*

View File

@@ -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 `
<div class="vip-level-card ${isCurrent ? 'current' : ''}">
${isCurrent ? '<span class="vip-level-badge">当前档位</span>' : ''}
@@ -1663,11 +1690,15 @@ async function generateWechatBindCode() {
<div class="vip-feature-item"><span class="vip-feature-icon"></span> 专属入场特效 & 横幅</div>
</div>
<div style="margin-top:auto; padding-top:10px;">
<div style="font-size:18px; font-weight:900; color:#e11d48; margin-bottom:10px;">¥${Number(v.price).toFixed(2)} <span style="font-size:11px; font-weight:normal; color:#94a3b8;">/ ${v.duration_days}</span></div>
<button onclick="buyVip(${v.id})" ${!d.vipPaymentEnabled ? 'disabled' : ''}
<div style="font-size:18px; font-weight:900; color:#e11d48; margin-bottom:5px;">
¥${Number(priceToDisplay).toFixed(2)}
<span style="font-size:11px; font-weight:normal; color:#94a3b8;">/ ${v.duration_days}</span>
</div>
${showUpgradeInfo ? `<div style="font-size:10px; color:#4f46e5; font-weight:bold; margin-bottom:8px;">已省 ¥${(v.price - v.upgrade_price).toFixed(2)}</div>` : ''}
<button onclick="buyVip(${v.id})" ${isDisabled ? 'disabled' : ''}
style="width:100%; border:none; border-radius:8px; padding:10px; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s;
background:${isCurrent ? '#fef3c7' : '#1e293b'}; color:${isCurrent ? '#b45309' : '#fff'};">
${!d.vipPaymentEnabled ? '支付暂未开启' : (isCurrent ? '立即续费' : '立即购买')}
background:${btnColor}; color:${btnTextColor}; ${isDisabled ? 'cursor:not-allowed;' : ''}">
${!d.vipPaymentEnabled && !isLower ? '支付暂未开启' : btnText}
</button>
</div>
</div>
@@ -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() {

View File

@@ -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 @@
<div class="vip-presence-banner__meta">
<span class="vip-presence-banner__icon">${escapeHtml(payload.presence_icon || '👑')}</span>
<span class="vip-presence-banner__level">${escapeHtml(payload.presence_level_name || '尊贵会员')}</span>
<span class="vip-presence-banner__type">${payload.presence_type === 'leave' ? '离场提示' : '闪耀登场'}</span>
<span class="vip-presence-banner__type">${bannerTypeLabel}</span>
</div>
<div class="vip-presence-banner__text" style="color:${styleConfig.accent};">${escapePresenceText(payload.presence_text)}</div>
</div>
@@ -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 = `

View File

@@ -165,7 +165,7 @@
</div>
@if ($vipPaymentEnabled)
<form action="{{ route('vip.payment.store') }}" method="POST" class="mt-6">
<form action="{{ route('vip.payment.store') }}" method="POST" class="mt-6" target="_blank">
@csrf
<input type="hidden" name="vip_level_id" value="{{ $vip->id }}">
<button type="submit"

View File

@@ -13,12 +13,26 @@ use App\Models\VipLevel;
use App\Models\VipPaymentOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;
/**
* VIP 支付集成功能测试
* 覆盖创建订单、异步开通会员以及聊天室内购买成功喜报等关键链路。
*/
class VipPaymentIntegrationTest extends TestCase
{
use RefreshDatabase;
/**
* 每个测试前清空 Redis避免聊天室在线状态与消息缓存互相污染。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
}
/**
* 测试用户可以发起 VIP 支付并跳转到支付中心页面
*/
@@ -140,30 +154,19 @@ class VipPaymentIntegrationTest extends TestCase
*/
public function test_vip_center_only_shows_current_user_records(): void
{
$currentUser = User::factory()->create(['username' => 'current-user']);
$otherUser = User::factory()->create(['username' => 'other-user']);
$vipLevel = VipLevel::factory()->create([
'name' => '星耀会员',
'price' => 66,
'duration_days' => 30,
]);
$this->seedVipPaymentConfig();
$currentUser = User::factory()->create();
$otherUser = User::factory()->create();
VipPaymentOrder::factory()->create([
'user_id' => $currentUser->id,
'vip_level_id' => $vipLevel->id,
'vip_name' => $vipLevel->name,
'status' => 'paid',
'order_no' => 'VPO_CURRENT_001',
'merchant_order_no' => 'MER_CURRENT_001',
]);
VipPaymentOrder::factory()->create([
'user_id' => $otherUser->id,
'vip_level_id' => $vipLevel->id,
'vip_name' => $vipLevel->name,
'status' => 'paid',
'order_no' => 'VPO_OTHER_001',
'merchant_order_no' => 'MER_OTHER_001',
]);
$response = $this->actingAs($currentUser)->get(route('vip.center'));
@@ -179,6 +182,8 @@ class VipPaymentIntegrationTest extends TestCase
*/
public function test_vip_member_can_update_custom_presence_messages(): void
{
$this->seedVipPaymentConfig();
$vipLevel = VipLevel::factory()->create([
'allow_custom_messages' => true,
]);
@@ -201,10 +206,12 @@ class VipPaymentIntegrationTest extends TestCase
}
/**
* 测试未开通该权限的用户不能保存自定义欢迎语和离开语
* 测试普通用户或不允许自定义的会员无法保存专属语句
*/
public function test_non_customizable_vip_member_cannot_update_custom_presence_messages(): void
{
$this->seedVipPaymentConfig();
$vipLevel = VipLevel::factory()->create([
'allow_custom_messages' => false,
]);
@@ -226,15 +233,15 @@ class VipPaymentIntegrationTest extends TestCase
}
/**
* 写入测试所需的支付中心配置
* 初始化支付中心模拟配置
*/
private function seedVipPaymentConfig(): void
{
// 这些配置与聊天室后台保存的数据结构保持一致,方便直接复用真实业务代码。
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_enabled'], ['body' => '1']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_base_url'], ['body' => 'https://novalink.test']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_app_key'], ['body' => 'chatroom-app']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_app_secret'], ['body' => 'chatroom-secret']);
Sysparam::query()->updateOrCreate(['alias' => 'vip_payment_timeout'], ['body' => '10']);
Sysparam::updateOrCreate(['alias' => 'vip_payment_enabled'], ['body' => '1']);
Sysparam::clearCache('vip_payment_enabled');
config(['services.novalink_payment_center.client_id' => 'chatroom-app']);
config(['services.novalink_payment_center.client_secret' => 'chatroom-secret']);
config(['services.novalink_payment_center.base_url' => 'https://novalink.test/api/open/v1']);
}
}