优化 红包 页面
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:红包领取成功广播事件(广播至领取者私有频道)
|
||||
* 文件功能:红包领取成功广播事件(广播至房间与领取者私有频道)
|
||||
*
|
||||
* 触发时机:RedPacketController::claim() 成功后广播,
|
||||
* 前端收到后弹出 Toast 通知展示到账金额。
|
||||
* 房间内在线用户收到后实时刷新剩余份数,领取者本人可同步收到到账提示。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -15,11 +15,18 @@ namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:广播礼包被领取后的实时状态
|
||||
*
|
||||
* 统一向房间频道推送剩余份数变化,同时向领取者私有频道推送到账结果,
|
||||
* 让红包弹窗与用户提示保持一致。
|
||||
*/
|
||||
class RedPacketClaimed implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -28,32 +35,49 @@ class RedPacketClaimed implements ShouldBroadcastNow
|
||||
* @param User $claimer 领取用户
|
||||
* @param int $amount 领取金额
|
||||
* @param int $envelopeId 红包 ID
|
||||
* @param int $roomId 房间 ID
|
||||
* @param int $remainingCount 剩余份数
|
||||
* @param string $type 红包类型
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly User $claimer,
|
||||
public readonly int $amount,
|
||||
public readonly int $envelopeId,
|
||||
public readonly int $roomId,
|
||||
public readonly int $remainingCount,
|
||||
public readonly string $type = 'gold',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至领取者私有频道。
|
||||
* 广播至房间频道与领取者私有频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->claimer->id)];
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
new PrivateChannel('user.'.$this->claimer->id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播领取结果与剩余份数。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$typeLabel = $this->type === 'exp' ? '经验' : '金币';
|
||||
|
||||
return [
|
||||
'envelope_id' => $this->envelopeId,
|
||||
'claimer_id' => $this->claimer->id,
|
||||
'claimer_username' => $this->claimer->username,
|
||||
'amount' => $this->amount,
|
||||
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
|
||||
'remaining_count' => $this->remainingCount,
|
||||
'type' => $this->type,
|
||||
'message' => "🧧 成功抢到 {$this->amount} {$typeLabel}礼包!",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室礼包的发包、查状态与抢包流程
|
||||
*
|
||||
* 负责礼包主记录创建、Redis 拆包金额管理、领取入账以及实时广播。
|
||||
*/
|
||||
class RedPacketController extends Controller
|
||||
{
|
||||
/** 礼包固定总数量 */
|
||||
@@ -307,8 +312,19 @@ class RedPacketController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
|
||||
}
|
||||
|
||||
// 广播领取事件(给自己的私有频道,前端弹 Toast)
|
||||
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
|
||||
// 重新读取红包统计,确保广播与响应使用的是最新剩余份数。
|
||||
$envelope->refresh();
|
||||
$remainingCount = $envelope->remainingCount();
|
||||
|
||||
// 广播领取事件:房间内所有在线用户实时刷新剩余份数,领取者本人同步收到到账通知。
|
||||
broadcast(new RedPacketClaimed(
|
||||
claimer: $user,
|
||||
amount: $amount,
|
||||
envelopeId: $envelope->id,
|
||||
roomId: $envelope->room_id,
|
||||
remainingCount: $remainingCount,
|
||||
type: $envelopeType,
|
||||
));
|
||||
|
||||
// 在聊天室发送领取播报(所有人可见)
|
||||
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
||||
@@ -335,6 +351,7 @@ class RedPacketController extends Controller
|
||||
'status' => 'success',
|
||||
'amount' => $amount,
|
||||
'type' => $envelopeType,
|
||||
'remaining_count' => $remainingCount,
|
||||
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}:{$balanceNow}。",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
* 2. showRedPacketModal() — 收到 RedPacketSent 事件后弹出红包卡片
|
||||
* 3. claimRedPacket() — 用户点击「立即抢红包」
|
||||
* 4. closeRedPacketModal() — 关闭红包弹窗
|
||||
* 5. WebSocket 监听 — 监听 red-packet.sent 广播事件
|
||||
* 5. WebSocket 监听 — 监听 red-packet.sent / red-packet.claimed 广播事件
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
@@ -616,6 +616,10 @@
|
||||
btn.textContent = '🎉 已抢到!';
|
||||
statusEl.style.color = '#16a34a';
|
||||
statusEl.textContent = `恭喜!您抢到了 ${data.amount} ${typeLabel}!`;
|
||||
const remainingEl = document.getElementById('rp-remaining');
|
||||
if (remainingEl && typeof data.remaining_count === 'number') {
|
||||
remainingEl.textContent = data.remaining_count;
|
||||
}
|
||||
|
||||
// 弹出全局 Toast
|
||||
window.chatToast.show({
|
||||
@@ -631,9 +635,17 @@
|
||||
} else {
|
||||
statusEl.style.color = '#dc2626';
|
||||
statusEl.textContent = data.message || '抢包失败';
|
||||
// 若已领过或已抢完则禁用按钮,否则解除禁用以重试
|
||||
if (data.message && (data.message.includes('已经领过') || data.message.includes('已被抢完') ||
|
||||
data.message.includes('已抢完'))) {
|
||||
const message = data.message || '';
|
||||
const shouldAutoClose = message.includes('已过期')
|
||||
|| message.includes('已被抢完')
|
||||
|| message.includes('已抢完')
|
||||
|| message.includes('红包已抢完或已过期');
|
||||
|
||||
// 若红包已经结束,则保持禁用并在 3 秒后自动关闭弹窗。
|
||||
if (shouldAutoClose) {
|
||||
btn.textContent = '礼包已结束';
|
||||
setTimeout(() => closeRedPacketModal(), 3000);
|
||||
} else if (message.includes('已经领过')) {
|
||||
btn.textContent = '已参与';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -652,11 +664,12 @@
|
||||
/**
|
||||
* 收到「系统传音」中的领取播报时,同步更新弹窗内的名单与剩余数。
|
||||
*
|
||||
* @param {string} username 领取者用户名
|
||||
* @param {number} amount 领取金额
|
||||
* @param {number} remaining 剩余份数
|
||||
* @param {string} username 领取者用户名
|
||||
* @param {number} amount 领取金额
|
||||
* @param {number} remaining 剩余份数
|
||||
* @param {'gold'|'exp'} [type] 红包类型
|
||||
*/
|
||||
window.updateRedPacketClaimsUI = function(username, amount, remaining) {
|
||||
window.updateRedPacketClaimsUI = function(username, amount, remaining, type = _rpType) {
|
||||
const remainingEl = document.getElementById('rp-remaining');
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = remaining;
|
||||
@@ -669,9 +682,10 @@
|
||||
}
|
||||
|
||||
listEl.style.display = 'block';
|
||||
const typeLabel = type === 'exp' ? '经验' : '金币';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'rp-claim-item';
|
||||
item.innerHTML = `<span>${username}</span><span>+${amount} 金币</span>`;
|
||||
item.innerHTML = `<span>${username}</span><span>+${amount} ${typeLabel}</span>`;
|
||||
itemsEl.prepend(item);
|
||||
|
||||
// 若已全部领完,更新按钮状态
|
||||
@@ -687,10 +701,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
// ── WebSocket 监听 red-packet.sent ───────────────
|
||||
// ── WebSocket 监听 red-packet.sent / red-packet.claimed ───────────────
|
||||
/**
|
||||
* 等待 Echo 就绪后注册 red-packet.sent 事件监听,
|
||||
* 每次收到新红包时弹出红包卡片弹窗。
|
||||
* 等待 Echo 就绪后注册红包相关事件监听,
|
||||
* 新红包弹窗展示,领取成功后实时刷新剩余份数。
|
||||
*/
|
||||
function setupRedPacketListener() {
|
||||
if (!window.Echo || !window.chatContext) {
|
||||
@@ -708,6 +722,18 @@
|
||||
e.expire_seconds,
|
||||
e.type || 'gold',
|
||||
);
|
||||
})
|
||||
.listen('.red-packet.claimed', (e) => {
|
||||
if (Number(e.envelope_id) !== Number(_rpEnvelopeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.updateRedPacketClaimsUI(
|
||||
e.claimer_username,
|
||||
e.amount,
|
||||
e.remaining_count,
|
||||
e.type || _rpType,
|
||||
);
|
||||
});
|
||||
console.log('RedPacketSent 监听器已注册');
|
||||
}
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\RedPacketClaimed;
|
||||
use App\Models\RedPacketEnvelope;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证礼包红包控制器的发包、领取与状态查询流程
|
||||
*/
|
||||
class RedPacketControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 方法功能:初始化红包测试所需的 Redis 与站长等级配置。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -20,7 +28,10 @@ class RedPacketControllerTest extends TestCase
|
||||
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
|
||||
}
|
||||
|
||||
public function test_normal_user_cannot_send_red_packet()
|
||||
/**
|
||||
* 方法功能:验证普通用户不能发送礼包。
|
||||
*/
|
||||
public function test_normal_user_cannot_send_red_packet(): void
|
||||
{
|
||||
$user = User::factory()->create(['user_level' => 10]);
|
||||
|
||||
@@ -33,7 +44,10 @@ class RedPacketControllerTest extends TestCase
|
||||
$response->assertJson(['status' => 'error']);
|
||||
}
|
||||
|
||||
public function test_superadmin_can_send_red_packet()
|
||||
/**
|
||||
* 方法功能:验证站长可以成功发出礼包并写入 Redis 拆包结果。
|
||||
*/
|
||||
public function test_superadmin_can_send_red_packet(): void
|
||||
{
|
||||
$admin = User::factory()->create(['user_level' => 100]);
|
||||
|
||||
@@ -57,7 +71,10 @@ class RedPacketControllerTest extends TestCase
|
||||
$this->assertEquals(10, Redis::llen("red_packet:{$envelope->id}:amounts"));
|
||||
}
|
||||
|
||||
public function test_cannot_send_multiple_active_packets_in_same_room()
|
||||
/**
|
||||
* 方法功能:验证同一房间内不可重复发送未结束的礼包。
|
||||
*/
|
||||
public function test_cannot_send_multiple_active_packets_in_same_room(): void
|
||||
{
|
||||
$admin = User::factory()->create(['user_level' => 100]);
|
||||
|
||||
@@ -74,7 +91,10 @@ class RedPacketControllerTest extends TestCase
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_user_can_claim_red_packet()
|
||||
/**
|
||||
* 方法功能:验证用户可以领取礼包并增加金币余额。
|
||||
*/
|
||||
public function test_user_can_claim_red_packet(): void
|
||||
{
|
||||
$admin = User::factory()->create(['user_level' => 100]);
|
||||
$user = User::factory()->create(['jjb' => 100]);
|
||||
@@ -104,7 +124,46 @@ class RedPacketControllerTest extends TestCase
|
||||
$this->assertGreaterThan(100, $user->fresh()->jjb);
|
||||
}
|
||||
|
||||
public function test_user_cannot_claim_same_packet_twice()
|
||||
/**
|
||||
* 方法功能:验证领取成功后会返回并广播最新剩余份数。
|
||||
*/
|
||||
public function test_claim_red_packet_returns_and_broadcasts_remaining_count(): void
|
||||
{
|
||||
Event::fake([RedPacketClaimed::class]);
|
||||
|
||||
$admin = User::factory()->create(['user_level' => 100]);
|
||||
$user = User::factory()->create(['jjb' => 100]);
|
||||
|
||||
$this->actingAs($admin)->postJson(route('command.red_packet.send'), [
|
||||
'room_id' => 1,
|
||||
'type' => 'gold',
|
||||
]);
|
||||
|
||||
$envelope = RedPacketEnvelope::query()->firstOrFail();
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('red_packet.claim', ['envelopeId' => $envelope->id]), [
|
||||
'room_id' => 1,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'status' => 'success',
|
||||
'remaining_count' => 9,
|
||||
]);
|
||||
|
||||
Event::assertDispatched(RedPacketClaimed::class, function (RedPacketClaimed $event) use ($user, $envelope): bool {
|
||||
return $event->claimer->is($user)
|
||||
&& $event->envelopeId === $envelope->id
|
||||
&& $event->roomId === 1
|
||||
&& $event->remainingCount === 9
|
||||
&& $event->type === 'gold';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证同一用户不能重复领取同一个礼包。
|
||||
*/
|
||||
public function test_user_cannot_claim_same_packet_twice(): void
|
||||
{
|
||||
$admin = User::factory()->create(['user_level' => 100]);
|
||||
$user = User::factory()->create();
|
||||
@@ -130,7 +189,10 @@ class RedPacketControllerTest extends TestCase
|
||||
$response->assertJson(['message' => '您已经领过这个礼包了']);
|
||||
}
|
||||
|
||||
public function test_can_check_packet_status()
|
||||
/**
|
||||
* 方法功能:验证可以查询礼包状态并识别当前用户是否已领取。
|
||||
*/
|
||||
public function test_can_check_packet_status(): void
|
||||
{
|
||||
$admin = User::factory()->create(['user_level' => 100]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
Reference in New Issue
Block a user