功能:站长礼包系统(金币/经验双类型)+ 后台用户编辑权限收紧(仅 id=1 超管)
新增功能:
- 礼包系统:superlevel 站长可发 888 数量 10 份礼包,支持金币/经验双类型
- 发包前三按钮选择(金币礼包 / 经验礼包 / 取消),使用 chatBanner 弹窗
- 聊天室系统公告含「立即抢包」按钮,金币红色/经验紫色配色区分
- WebSocket 实时推送红包弹窗卡片至所有在线用户
- Redis LPOP 原子分发 + 数据库 unique 约束防重领,并发安全
- 弹窗打开自动拉取服务端最新状态(剩余数量/已领/过期实时刷新)
- 新增 GET /red-packet/{id}/status 状态查询接口
- 新增 CurrencySource::RED_PACKET_RECV / RED_PACKET_RECV_EXP 枚举
安全加固:
- 后台用户编辑/强杀按钮仅 id=1 超管可见(前端隐藏 + 后端 403 双重拦截)
This commit is contained in:
@@ -90,6 +90,12 @@ enum CurrencySource: string
|
||||
/** 老虎机诅咒额外扣除 */
|
||||
case SLOT_CURSE = 'slot_curse';
|
||||
|
||||
/** 领取礼包红包——金币(用户抢到金币礼包时收入) */
|
||||
case RED_PACKET_RECV = 'red_packet_recv';
|
||||
|
||||
/** 领取礼包红包——经验(用户抢到经验礼包时收入) */
|
||||
case RED_PACKET_RECV_EXP = 'red_packet_recv_exp';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -119,6 +125,8 @@ enum CurrencySource: string
|
||||
self::SLOT_SPIN => '老虎机转动',
|
||||
self::SLOT_WIN => '老虎机中奖',
|
||||
self::SLOT_CURSE => '老虎机诅咒',
|
||||
self::RED_PACKET_RECV => '领取礼包红包(金币)',
|
||||
self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
64
app/Events/RedPacketClaimed.php
Normal file
64
app/Events/RedPacketClaimed.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:红包领取成功广播事件(广播至领取者私有频道)
|
||||
*
|
||||
* 触发时机:RedPacketController::claim() 成功后广播,
|
||||
* 前端收到后弹出 Toast 通知展示到账金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @param User $claimer 领取用户
|
||||
* @param int $amount 领取金额
|
||||
* @param int $envelopeId 红包 ID
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly User $claimer,
|
||||
public readonly int $amount,
|
||||
public readonly int $envelopeId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至领取者私有频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->claimer->id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'envelope_id' => $this->envelopeId,
|
||||
'amount' => $this->amount,
|
||||
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'red-packet.claimed';
|
||||
}
|
||||
}
|
||||
75
app/Events/RedPacketSent.php
Normal file
75
app/Events/RedPacketSent.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:礼包红包发出事件(广播至房间所有用户)
|
||||
*
|
||||
* 触发时机:AdminCommandController::sendRedPacket() 成功后广播,
|
||||
* 前端接收后显示红包卡片弹窗,并在聊天窗口追加系统公告。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RedPacketSent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param int $roomId 房间 ID
|
||||
* @param int $envelopeId 红包 ID
|
||||
* @param string $senderUsername 发包人用户名
|
||||
* @param int $totalAmount 总金额(金币)
|
||||
* @param int $totalCount 总份数
|
||||
* @param int $expireSeconds 过期秒数(用于前端倒计时)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly int $envelopeId,
|
||||
public readonly string $senderUsername,
|
||||
public readonly int $totalAmount,
|
||||
public readonly int $totalCount,
|
||||
public readonly int $expireSeconds,
|
||||
public readonly string $type = 'gold',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间 Presence 频道(所有在线用户均可收到)。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.'.$this->roomId)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:前端渲染红包弹窗所需字段。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'envelope_id' => $this->envelopeId,
|
||||
'sender_username' => $this->senderUsername,
|
||||
'total_amount' => $this->totalAmount,
|
||||
'total_count' => $this->totalCount,
|
||||
'expire_seconds' => $this->expireSeconds,
|
||||
'type' => $this->type,
|
||||
];
|
||||
}
|
||||
|
||||
/** 自定义事件名称(前端监听时使用)。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'red-packet.sent';
|
||||
}
|
||||
}
|
||||
@@ -88,9 +88,17 @@ class UserManagerController extends Controller
|
||||
*/
|
||||
public function update(Request $request, User $user): JsonResponse|RedirectResponse
|
||||
{
|
||||
$targetUser = $user;
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
|
||||
if ($currentUser->id !== 1) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅超级管理员(id=1)可编辑用户信息。'], 403);
|
||||
}
|
||||
abort(403, '仅超级管理员(id=1)可编辑用户信息。');
|
||||
}
|
||||
|
||||
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
|
||||
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
|
||||
@@ -177,9 +185,14 @@ class UserManagerController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$targetUser = $user;
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可删除用户
|
||||
if ($currentUser->id !== 1) {
|
||||
abort(403, '仅超级管理员(id=1)可删除用户。');
|
||||
}
|
||||
|
||||
// 越权防护:不允许删除同级或更高等级的账号
|
||||
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
|
||||
abort(403, '权限不足:无法删除同级或高级账号!');
|
||||
|
||||
365
app/Http/Controllers/RedPacketController.php
Normal file
365
app/Http/Controllers/RedPacketController.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室礼包(红包)控制器
|
||||
*
|
||||
* 提供两个核心接口:
|
||||
* - send() :superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
|
||||
* - claim() :在线用户抢礼包(先到先得,每人一份)
|
||||
*
|
||||
* 接入 UserCurrencyService 记录所有货币变动流水。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\RedPacketClaimed;
|
||||
use App\Events\RedPacketSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\RedPacketClaim;
|
||||
use App\Models\RedPacketEnvelope;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RedPacketController extends Controller
|
||||
{
|
||||
/** 礼包固定总数量 */
|
||||
private const TOTAL_AMOUNT = 888;
|
||||
|
||||
/** 礼包固定份数 */
|
||||
private const TOTAL_COUNT = 10;
|
||||
|
||||
/** 礼包有效期(秒) */
|
||||
private const EXPIRE_SECONDS = 120;
|
||||
|
||||
/**
|
||||
* 构造函数:注入依赖服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* superlevel 站长凭空发出礼包。
|
||||
*
|
||||
* 不扣发包人自身货币,888 数量凭空发出分 10 份。
|
||||
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
|
||||
*
|
||||
* @param Request $request 需包含 room_id 和 type(gold / exp)
|
||||
*/
|
||||
public function send(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
'type' => 'required|in:gold,exp',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$type = $request->input('type'); // 'gold' 或 'exp'
|
||||
|
||||
// 权限校验:仅 superlevel 可发礼包
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
if ($user->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
|
||||
}
|
||||
|
||||
// 检查该用户在此房间是否有进行中的红包(防止刷包)
|
||||
$activeExists = RedPacketEnvelope::query()
|
||||
->where('sender_id', $user->id)
|
||||
->where('room_id', $roomId)
|
||||
->where('status', 'active')
|
||||
->where('expires_at', '>', now())
|
||||
->exists();
|
||||
|
||||
if ($activeExists) {
|
||||
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
|
||||
}
|
||||
|
||||
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于 TOTAL_AMOUNT)
|
||||
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
|
||||
|
||||
// 货币展示文案
|
||||
$typeLabel = $type === 'exp' ? '经验' : '金币';
|
||||
$typeIcon = $type === 'exp' ? '✨' : '🪙';
|
||||
$btnBg = $type === 'exp'
|
||||
? 'linear-gradient(135deg,#7c3aed,#4f46e5)'
|
||||
: 'linear-gradient(135deg,#dc2626,#ea580c)';
|
||||
|
||||
// 事务:创建红包记录 + Redis 写入分额
|
||||
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
|
||||
// 创建红包主记录(凭空发出,不扣发包人货币)
|
||||
$envelope = RedPacketEnvelope::create([
|
||||
'sender_id' => $user->id,
|
||||
'sender_username' => $user->username,
|
||||
'room_id' => $roomId,
|
||||
'type' => $type,
|
||||
'total_amount' => self::TOTAL_AMOUNT,
|
||||
'total_count' => self::TOTAL_COUNT,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'status' => 'active',
|
||||
'expires_at' => now()->addSeconds(self::EXPIRE_SECONDS),
|
||||
]);
|
||||
|
||||
// 将拆分好的数量序列存入 Redis(List,LPOP 抢红包)
|
||||
$key = "red_packet:{$envelope->id}:amounts";
|
||||
foreach ($amounts as $amt) {
|
||||
\Illuminate\Support\Facades\Redis::rpush($key, $amt);
|
||||
}
|
||||
// 多留 60s,确保领完后仍可回查
|
||||
\Illuminate\Support\Facades\Redis::expire($key, self::EXPIRE_SECONDS + 60);
|
||||
|
||||
return $envelope;
|
||||
});
|
||||
|
||||
// 广播系统公告,含可点击「立即抢包」按钮
|
||||
$btnHtml = '<button onclick="showRedPacketModal('
|
||||
.$envelope->id
|
||||
.',\''.$user->username.'\','
|
||||
.self::TOTAL_AMOUNT.','
|
||||
.self::TOTAL_COUNT.','
|
||||
.self::EXPIRE_SECONDS
|
||||
.',\''.$type.'\''
|
||||
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
|
||||
.'color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:12px;font-weight:bold;'
|
||||
.'box-shadow:0 2px 6px rgba(0,0,0,0.3);">'.$typeIcon.' 立即抢包</button>';
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '',
|
||||
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 广播红包事件(触发前端弹出红包卡片)
|
||||
broadcast(new RedPacketSent(
|
||||
roomId: $roomId,
|
||||
envelopeId: $envelope->id,
|
||||
senderUsername: $user->username,
|
||||
totalAmount: self::TOTAL_AMOUNT,
|
||||
totalCount: self::TOTAL_COUNT,
|
||||
expireSeconds: self::EXPIRE_SECONDS,
|
||||
type: $type,
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT." 份",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询礼包当前状态(弹窗打开时实时刷新用)。
|
||||
*
|
||||
* 返回:剩余份数、是否已过期、当前用户是否已领取。
|
||||
*
|
||||
* @param int $envelopeId 红包 ID
|
||||
*/
|
||||
public function status(int $envelopeId): JsonResponse
|
||||
{
|
||||
$envelope = RedPacketEnvelope::find($envelopeId);
|
||||
if (! $envelope) {
|
||||
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$isExpired = $envelope->expires_at->isPast();
|
||||
$remainingCount = $envelope->remainingCount();
|
||||
$hasClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'remaining_count' => $remainingCount,
|
||||
'total_count' => $envelope->total_count,
|
||||
'envelope_status' => $envelope->status,
|
||||
'is_expired' => $isExpired,
|
||||
'has_claimed' => $hasClaimed,
|
||||
'type' => $envelope->type ?? 'gold',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户抢礼包(先到先得)。
|
||||
*
|
||||
* 使用 Redis LPOP 原子操作获取数量,再写入数据库流水。
|
||||
* 重复领取通过 unique 约束保障幂等性。
|
||||
* 按红包 type 字段决定入账金币还是经验。
|
||||
*
|
||||
* @param Request $request 需包含 room_id
|
||||
* @param int $envelopeId 红包 ID
|
||||
*/
|
||||
public function claim(Request $request, int $envelopeId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
|
||||
// 加载红包记录
|
||||
$envelope = RedPacketEnvelope::find($envelopeId);
|
||||
if (! $envelope) {
|
||||
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
|
||||
}
|
||||
|
||||
// 检查红包是否可领
|
||||
if (! $envelope->isClaimable()) {
|
||||
return response()->json(['status' => 'error', 'message' => '红包已抢完或已过期'], 422);
|
||||
}
|
||||
|
||||
// 检查是否已领取过
|
||||
$alreadyClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
if ($alreadyClaimed) {
|
||||
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
|
||||
}
|
||||
|
||||
// 从 Redis 原子 POP 一份数量
|
||||
$redisKey = "red_packet:{$envelopeId}:amounts";
|
||||
$amount = \Illuminate\Support\Facades\Redis::lpop($redisKey);
|
||||
if ($amount === null || $amount === false) {
|
||||
return response()->json(['status' => 'error', 'message' => '礼包已被抢完!'], 422);
|
||||
}
|
||||
$amount = (int) $amount;
|
||||
// 兼容旧记录(type 字段可能为 null)
|
||||
$envelopeType = $envelope->type ?? 'gold';
|
||||
|
||||
// 事务:写领取记录 + 更新统计 + 货币入账
|
||||
try {
|
||||
DB::transaction(function () use ($envelope, $user, $amount, $roomId, $envelopeType): void {
|
||||
// 写领取记录(unique 约束保障不重复)
|
||||
RedPacketClaim::create([
|
||||
'envelope_id' => $envelope->id,
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'amount' => $amount,
|
||||
'claimed_at' => now(),
|
||||
]);
|
||||
|
||||
// 更新红包统计
|
||||
$envelope->increment('claimed_count');
|
||||
$envelope->increment('claimed_amount', $amount);
|
||||
|
||||
// 若已全部领完,关闭红包
|
||||
$envelope->refresh();
|
||||
if ($envelope->claimed_count >= $envelope->total_count) {
|
||||
$envelope->update(['status' => 'completed']);
|
||||
}
|
||||
|
||||
// 按类型入账(金币或经验)
|
||||
if ($envelopeType === 'exp') {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'exp',
|
||||
$amount,
|
||||
CurrencySource::RED_PACKET_RECV_EXP,
|
||||
"抢到礼包 {$amount} 经验(红包#{$envelope->id})",
|
||||
$roomId,
|
||||
);
|
||||
} else {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'gold',
|
||||
$amount,
|
||||
CurrencySource::RED_PACKET_RECV,
|
||||
"抢到礼包 {$amount} 金币(红包#{$envelope->id})",
|
||||
$roomId,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// 并发重复领取:将数量放回 Redis(补偿)
|
||||
\Illuminate\Support\Facades\Redis::rpush($redisKey, $amount);
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
|
||||
}
|
||||
|
||||
// 广播领取事件(给自己的私有频道,前端弹 Toast)
|
||||
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
|
||||
|
||||
// 在聊天室发送领取播报(所有人可见)
|
||||
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
||||
$typeIcon = $envelopeType === 'exp' ? '✨' : '🪙';
|
||||
$claimedMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '',
|
||||
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $claimedMsg);
|
||||
broadcast(new MessageSent($roomId, $claimedMsg));
|
||||
SaveMessageJob::dispatch($claimedMsg);
|
||||
|
||||
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
|
||||
$balanceNow = $user->fresh()->$balanceField;
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'amount' => $amount,
|
||||
'type' => $envelopeType,
|
||||
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}:{$balanceNow}。",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机拆分礼包数量。
|
||||
*
|
||||
* 使用「二倍均值法」:每次随机数量不超过剩余均值的 2 倍,
|
||||
* 保证每份至少 1 且总额精确等于 totalAmount。
|
||||
*
|
||||
* @param int $total 总数量
|
||||
* @param int $count 份数
|
||||
* @return int[] 每份数量数组
|
||||
*/
|
||||
private function splitAmount(int $total, int $count): array
|
||||
{
|
||||
$amounts = [];
|
||||
$remaining = $total;
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
$leftCount = $count - $i;
|
||||
$max = min((int) floor($remaining / $leftCount * 2), $remaining - $leftCount);
|
||||
$max = max(1, $max);
|
||||
$amount = random_int(1, $max);
|
||||
$amounts[] = $amount;
|
||||
$remaining -= $amount;
|
||||
}
|
||||
|
||||
// 最后一份为剩余全部
|
||||
$amounts[] = $remaining;
|
||||
|
||||
// 打乱顺序,避免后来者必得少
|
||||
shuffle($amounts);
|
||||
|
||||
return $amounts;
|
||||
}
|
||||
}
|
||||
50
app/Models/RedPacketClaim.php
Normal file
50
app/Models/RedPacketClaim.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:红包领取记录 Model
|
||||
*
|
||||
* 每条记录代表某个用户领取了某个红包的一份,
|
||||
* envelope_id + user_id 联合唯一约束保证幂等性。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class RedPacketClaim extends Model
|
||||
{
|
||||
/** 无自动 updated_at,只记录 claimed_at */
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'envelope_id',
|
||||
'user_id',
|
||||
'username',
|
||||
'amount',
|
||||
'claimed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 类型转换。
|
||||
*/
|
||||
public function casts(): array
|
||||
{
|
||||
return [
|
||||
'claimed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联红包主表。
|
||||
*
|
||||
* @return BelongsTo<RedPacketEnvelope, RedPacketClaim>
|
||||
*/
|
||||
public function envelope(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RedPacketEnvelope::class, 'envelope_id');
|
||||
}
|
||||
}
|
||||
80
app/Models/RedPacketEnvelope.php
Normal file
80
app/Models/RedPacketEnvelope.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室礼包(红包)主表 Model
|
||||
*
|
||||
* 由 superlevel 管理员在聊天室内发出,每次固定 888 金币分 10 个名额。
|
||||
* 先到先得,领完或超时后自动关闭。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class RedPacketEnvelope extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'sender_id',
|
||||
'sender_username',
|
||||
'room_id',
|
||||
'type',
|
||||
'total_amount',
|
||||
'total_count',
|
||||
'claimed_count',
|
||||
'claimed_amount',
|
||||
'status',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 类型转换。
|
||||
*/
|
||||
public function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联领取记录。
|
||||
*
|
||||
* @return HasMany<RedPacketClaim>
|
||||
*/
|
||||
public function claims(): HasMany
|
||||
{
|
||||
return $this->hasMany(RedPacketClaim::class, 'envelope_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断红包当前是否可以被领取。
|
||||
*
|
||||
* 条件:状态为 active + 未过期 + 未领满。
|
||||
*/
|
||||
public function isClaimable(): bool
|
||||
{
|
||||
return $this->status === 'active'
|
||||
&& $this->expires_at->isFuture()
|
||||
&& $this->claimed_count < $this->total_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 剩余可领份数。
|
||||
*/
|
||||
public function remainingCount(): int
|
||||
{
|
||||
return max(0, $this->total_count - $this->claimed_count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 剩余金额。
|
||||
*/
|
||||
public function remainingAmount(): int
|
||||
{
|
||||
return max(0, $this->total_amount - $this->claimed_amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建聊天室礼包(红包)相关数据表
|
||||
*
|
||||
* red_packet_envelopes:红包主表(superlevel 用户发出的 888 金币红包)
|
||||
* red_packet_claims:红包领取记录(先到先得,每人只能领一次)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建红包主表与领取记录表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 红包主表
|
||||
Schema::create('red_packet_envelopes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('sender_id')->comment('发包用户 ID');
|
||||
$table->string('sender_username', 20)->comment('发包用户名(快照)');
|
||||
$table->unsignedInteger('room_id')->comment('发出所在房间 ID');
|
||||
$table->unsignedInteger('total_amount')->comment('红包总金额(金币)');
|
||||
$table->unsignedInteger('total_count')->comment('红包总份数');
|
||||
$table->unsignedInteger('claimed_count')->default(0)->comment('已被领取份数');
|
||||
$table->unsignedInteger('claimed_amount')->default(0)->comment('已被领取总金额');
|
||||
$table->string('status', 10)->default('active')->comment('状态:active / completed / expired');
|
||||
$table->timestamp('expires_at')->comment('过期时间(超时未领完则关闭)');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['room_id', 'status']);
|
||||
$table->index('sender_id');
|
||||
});
|
||||
|
||||
// 领取记录表
|
||||
Schema::create('red_packet_claims', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('envelope_id')->comment('关联红包 ID');
|
||||
$table->unsignedBigInteger('user_id')->comment('领取用户 ID');
|
||||
$table->string('username', 20)->comment('领取用户名(快照)');
|
||||
$table->unsignedInteger('amount')->comment('本次领取金额');
|
||||
$table->timestamp('claimed_at')->useCurrent()->comment('领取时间');
|
||||
|
||||
// 每人每个红包只能领一次
|
||||
$table->unique(['envelope_id', 'user_id']);
|
||||
$table->index('envelope_id');
|
||||
$table->foreign('envelope_id')->references('id')->on('red_packet_envelopes')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除红包相关表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('red_packet_claims');
|
||||
Schema::dropIfExists('red_packet_envelopes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为礼包红包主表新增货币类型字段
|
||||
*
|
||||
* type 字段区分本次红包发放的是金币(gold)还是经验(exp)。
|
||||
* 默认 gold,兼容已有记录。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 新增 type 字段到 red_packet_envelopes 表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('red_packet_envelopes', function (Blueprint $table) {
|
||||
// 货币类型:gold = 金币,exp = 经验值
|
||||
$table->string('type', 10)->default('gold')->after('room_id')->comment('货币类型:gold / exp');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除 type 字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('red_packet_envelopes', function (Blueprint $table) {
|
||||
$table->dropColumn('type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -130,32 +130,37 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-4 text-right space-x-2 relative">
|
||||
<button
|
||||
@click="editingUser = {
|
||||
id: {{ $user->id }},
|
||||
username: '{{ addslashes($user->username) }}',
|
||||
exp_num: {{ $user->exp_num ?? 0 }},
|
||||
jjb: {{ $user->jjb ?? 0 }},
|
||||
meili: {{ $user->meili ?? 0 }},
|
||||
sex: '{{ $user->sex }}',
|
||||
qianming: '{{ addslashes($user->qianming ?? '') }}',
|
||||
visit_num: {{ $user->visit_num ?? 0 }},
|
||||
vip_level_id: '{{ $user->vip_level_id ?? '' }}',
|
||||
hy_time: '{{ $user->hy_time ? $user->hy_time->format('Y-m-d') : '' }}',
|
||||
requestUrl: '{{ route('admin.users.update', $user->id) }}'
|
||||
}; showEditModal = true"
|
||||
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-3 py-1.5 rounded hover:bg-indigo-600 hover:text-white transition cursor-pointer">
|
||||
详细 / 修改
|
||||
</button>
|
||||
|
||||
<form action="{{ route('admin.users.destroy', $user->id) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('危险:确定彻底物理清除用户 [{{ $user->username }}] 吗?数据不可恢复!')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="text-xs bg-red-50 text-red-600 font-bold px-3 py-1.5 rounded hover:bg-red-600 hover:text-white transition cursor-pointer">
|
||||
强杀
|
||||
@if (auth()->id() === 1)
|
||||
<button
|
||||
@click="editingUser = {
|
||||
id: {{ $user->id }},
|
||||
username: '{{ addslashes($user->username) }}',
|
||||
exp_num: {{ $user->exp_num ?? 0 }},
|
||||
jjb: {{ $user->jjb ?? 0 }},
|
||||
meili: {{ $user->meili ?? 0 }},
|
||||
sex: '{{ $user->sex }}',
|
||||
qianming: '{{ addslashes($user->qianming ?? '') }}',
|
||||
visit_num: {{ $user->visit_num ?? 0 }},
|
||||
vip_level_id: '{{ $user->vip_level_id ?? '' }}',
|
||||
hy_time: '{{ $user->hy_time ? $user->hy_time->format('Y-m-d') : '' }}',
|
||||
requestUrl: '{{ route('admin.users.update', $user->id) }}'
|
||||
}; showEditModal = true"
|
||||
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-3 py-1.5 rounded hover:bg-indigo-600 hover:text-white transition cursor-pointer">
|
||||
详细 / 修改
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action="{{ route('admin.users.destroy', $user->id) }}" method="POST"
|
||||
class="inline"
|
||||
onsubmit="return confirm('危险:确定彻底物理清除用户 [{{ $user->username }}] 吗?数据不可恢复!')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="text-xs bg-red-50 text-red-600 font-bold px-3 py-1.5 rounded hover:bg-red-600 hover:text-white transition cursor-pointer">
|
||||
强杀
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-xs text-gray-300 italic">仅超管可操作</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
||||
@@ -95,6 +95,9 @@
|
||||
<button type="button" onclick="adminClearScreen()"
|
||||
style="font-size: 11px; padding: 1px 6px; background: #dc2626; color: #fff; border: none; border-radius: 2px; cursor: pointer;">🧹
|
||||
清屏</button>
|
||||
<button type="button" id="red-packet-btn" onclick="sendRedPacket()"
|
||||
style="font-size: 11px; padding: 1px 6px; background: linear-gradient(135deg, #dc2626, #d97706); color: #fff; border: none; border-radius: 2px; cursor: pointer; font-weight: bold;">🧧
|
||||
礼包</button>
|
||||
{{-- 全屏特效按钮组(仅管理员可见) --}}
|
||||
<button type="button" onclick="triggerEffect('fireworks')" title="全屏烟花"
|
||||
style="font-size: 11px; padding: 1px 6px; background: #ea580c; color: #fff; border: none; border-radius: 2px; cursor: pointer;">🎆
|
||||
|
||||
@@ -2200,3 +2200,617 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{{-- ═══════════════════════════════════════ --}}
|
||||
{{-- 礼包红包弹窗(HTML + CSS + 交互脚本) --}}
|
||||
{{-- ═══════════════════════════════════════ --}}
|
||||
<style>
|
||||
/* 红包弹窗遮罩 */
|
||||
#red-packet-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10500;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
animation: rpFadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes rpFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 红包卡片主体 */
|
||||
#red-packet-card {
|
||||
width: 300px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(220, 38, 38, 0.4);
|
||||
animation: rpCardIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes rpCardIn {
|
||||
from {
|
||||
transform: scale(0.7) translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 红包顶部区 */
|
||||
#rp-header {
|
||||
background: linear-gradient(160deg, #dc2626 0%, #b91c1c 50%, #991b1b 100%);
|
||||
padding: 24px 20px 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#rp-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(255, 100, 0, 0.3) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rp-emoji {
|
||||
font-size: 52px;
|
||||
display: block;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
|
||||
animation: rpBounce 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rpBounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
.rp-sender {
|
||||
color: #fde68a;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rp-title {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.rp-amount {
|
||||
color: #fde68a;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-top: 6px;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.rp-amount small {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 红包底部区 */
|
||||
#rp-body {
|
||||
background: #fff8f0;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.rp-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 倒计时条 */
|
||||
#rp-timer-bar-wrap {
|
||||
background: #fee2e2;
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#rp-timer-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #dc2626, #f97316);
|
||||
border-radius: 4px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
/* 领取按钮 */
|
||||
#rp-claim-btn {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
background: linear-gradient(135deg, #dc2626, #ea580c);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
|
||||
transition: opacity .2s, transform .15s;
|
||||
}
|
||||
|
||||
#rp-claim-btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#rp-claim-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#rp-claim-btn:disabled {
|
||||
background: #9ca3af;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 已领取名单 */
|
||||
#rp-claims-list {
|
||||
margin-top: 12px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px dashed #fca5a5;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.rp-claim-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.rp-claim-item span:last-child {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
#rp-close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#rp-close-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 状态提示 */
|
||||
#rp-status-msg {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
min-height: 16px;
|
||||
color: #16a34a;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{-- 红包弹窗 DOM --}}
|
||||
<div id="red-packet-modal">
|
||||
<div id="red-packet-card">
|
||||
{{-- 顶部标题区 --}}
|
||||
<div id="rp-header">
|
||||
<span id="rp-close-btn" onclick="closeRedPacketModal()">✕</span>
|
||||
<span class="rp-emoji">🧧</span>
|
||||
<div class="rp-sender" id="rp-sender-name">xxx 的礼包</div>
|
||||
<div class="rp-title">聊天室专属礼包</div>
|
||||
<div class="rp-amount"><small>总计 </small><span id="rp-total-amount">888</span><small> 金币</small></div>
|
||||
</div>
|
||||
{{-- 底部操作区 --}}
|
||||
<div id="rp-body">
|
||||
<div class="rp-info-row">
|
||||
<span>剩余份数:<b id="rp-remaining">10</b> / <span id="rp-total-count">10</span> 份</span>
|
||||
<span>倒计时:<b id="rp-countdown">120</b>s</span>
|
||||
</div>
|
||||
<div id="rp-timer-bar-wrap">
|
||||
<div id="rp-timer-bar" style="width:100%;"></div>
|
||||
</div>
|
||||
<button id="rp-claim-btn" onclick="claimRedPacket()">🧧 立即抢红包</button>
|
||||
<div id="rp-status-msg"></div>
|
||||
{{-- 领取名单 --}}
|
||||
<div id="rp-claims-list" style="display:none;">
|
||||
<div style="font-size:11px; color:#92400e; margin-bottom:4px; font-weight:bold;">已领取名单:</div>
|
||||
<div id="rp-claims-items"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 礼包红包前端交互模块
|
||||
*
|
||||
* 功能:
|
||||
* 1. sendRedPacket() — superlevel 点击「礼包」按钮后确认发包
|
||||
* 2. showRedPacketModal() — 收到 RedPacketSent 事件后弹出红包卡片
|
||||
* 3. claimRedPacket() — 用户点击「立即抢红包」
|
||||
* 4. closeRedPacketModal() — 关闭红包弹窗
|
||||
* 5. WebSocket 监听 — 监听 red-packet.sent 广播事件
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 当前红包状态
|
||||
let _rpEnvelopeId = null; // 当前红包 ID
|
||||
let _rpExpireAt = null; // 过期时间戳(ms)
|
||||
let _rpTotalSeconds = 120; // 总倒计时秒数
|
||||
let _rpTimer = null; // 倒计时定时器
|
||||
let _rpClaimed = false; // 本次会话是否已领取
|
||||
|
||||
// ── 发包确认 ───────────────────────────────────────
|
||||
/**
|
||||
* superlevel 点击「礼包」按鈕,弹出 chatBanner 三按鈕选择类型后发包。
|
||||
*/
|
||||
window.sendRedPacket = function() {
|
||||
window.chatBanner.show({
|
||||
icon: '🧧',
|
||||
title: '发出礼包',
|
||||
name: '选择礼包类型',
|
||||
body: '将发出 <b>888</b> 数量共 <b>10</b> 份的礼包,系统凭空发放,房间成员先到先得!',
|
||||
gradient: ['#991b1b', '#dc2626', '#ea580c'],
|
||||
titleColor: '#fde68a',
|
||||
autoClose: 0,
|
||||
buttons: [{
|
||||
label: '🪙 金币礼包',
|
||||
color: '#d97706',
|
||||
onClick(btn, close) {
|
||||
close();
|
||||
doSendRedPacket('gold');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '✨ 经验礼包',
|
||||
color: '#7c3aed',
|
||||
onClick(btn, close) {
|
||||
close();
|
||||
doSendRedPacket('exp');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '取消',
|
||||
color: 'rgba(255,255,255,0.15)',
|
||||
onClick(btn, close) {
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 实际发包请求(由 chatBanner 按鈕回调触发)。
|
||||
*
|
||||
* @param {'gold'|'exp'} type 货币类型
|
||||
*/
|
||||
async function doSendRedPacket(type) {
|
||||
const btn = document.getElementById('red-packet-btn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中…';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/command/red-packet/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_id: window.chatContext.roomId,
|
||||
type
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.status !== 'success') {
|
||||
await window.chatDialog.alert(data.message || '发送失败', '操作失败', '#cc4444');
|
||||
}
|
||||
// 成功后 WebSocket 广播 RedPacketSent,前端自动弹出红包卡片
|
||||
} catch (e) {
|
||||
await window.chatDialog.alert('发送失败:' + e.message, '操作失败', '#cc4444');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '🧧 礼包';
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示红包弹窗,并启动倒计时。
|
||||
*
|
||||
* @param {number} envelopeId 红包 ID
|
||||
* @param {string} senderUsername 发包人用户名
|
||||
* @param {number} totalAmount 总数量
|
||||
* @param {number} totalCount 总份数
|
||||
* @param {number} expireSeconds 有效秒数
|
||||
* @param {'gold'|'exp'} type 货币类型
|
||||
*/
|
||||
window.showRedPacketModal = function(envelopeId, senderUsername, totalAmount, totalCount, expireSeconds,
|
||||
type) {
|
||||
_rpEnvelopeId = envelopeId;
|
||||
_rpClaimed = false;
|
||||
_rpTotalSeconds = expireSeconds;
|
||||
_rpExpireAt = Date.now() + expireSeconds * 1000;
|
||||
|
||||
// 根据类型调整配色和标签
|
||||
const isExp = (type === 'exp');
|
||||
const typeIcon = isExp ? '✨' : '🪙';
|
||||
const typeName = isExp ? '经验' : '金币';
|
||||
const headerBg = isExp ?
|
||||
'linear-gradient(160deg,#6d28d9 0%,#5b21b6 50%,#4c1d95 100%)' :
|
||||
'linear-gradient(160deg,#dc2626 0%,#b91c1c 50%,#991b1b 100%)';
|
||||
const claimBg = isExp ?
|
||||
'linear-gradient(135deg,#7c3aed,#4f46e5)' :
|
||||
'linear-gradient(135deg,#dc2626,#ea580c)';
|
||||
|
||||
// 应用配色
|
||||
document.getElementById('rp-header').style.background = headerBg;
|
||||
document.getElementById('rp-claim-btn').style.background = claimBg;
|
||||
|
||||
// 填入数据
|
||||
document.getElementById('rp-sender-name').textContent = senderUsername + ' 的礼包';
|
||||
document.getElementById('rp-total-amount').textContent = totalAmount;
|
||||
document.getElementById('rp-total-count').textContent = totalCount;
|
||||
document.getElementById('rp-remaining').textContent = totalCount;
|
||||
document.getElementById('rp-countdown').textContent = expireSeconds;
|
||||
document.getElementById('rp-timer-bar').style.width = '100%';
|
||||
document.getElementById('rp-status-msg').textContent = '';
|
||||
document.getElementById('rp-claims-list').style.display = 'none';
|
||||
document.getElementById('rp-claims-items').innerHTML = '';
|
||||
|
||||
// 更新卡片标题信息
|
||||
document.querySelector('.rp-emoji').textContent = typeIcon;
|
||||
document.querySelector('.rp-title').textContent = typeName + '礼包';
|
||||
const amountEl = document.getElementById('rp-total-amount');
|
||||
amountEl.nextSibling.textContent = ' ' + typeName; // small 标签
|
||||
|
||||
const claimBtn = document.getElementById('rp-claim-btn');
|
||||
claimBtn.disabled = false;
|
||||
claimBtn.textContent = typeIcon + ' 立即抢包';
|
||||
|
||||
// 显示弹窗
|
||||
document.getElementById('red-packet-modal').style.display = 'flex';
|
||||
|
||||
// 启动倒计时
|
||||
clearInterval(_rpTimer);
|
||||
_rpTimer = setInterval(() => {
|
||||
const remaining = Math.max(0, Math.ceil((_rpExpireAt - Date.now()) / 1000));
|
||||
document.getElementById('rp-countdown').textContent = remaining;
|
||||
document.getElementById('rp-timer-bar').style.width =
|
||||
(remaining / _rpTotalSeconds * 100) + '%';
|
||||
|
||||
if (remaining <= 0) {
|
||||
clearInterval(_rpTimer);
|
||||
document.getElementById('rp-claim-btn').disabled = true;
|
||||
document.getElementById('rp-claim-btn').textContent = '礼包已过期';
|
||||
document.getElementById('rp-status-msg').style.color = '#9ca3af';
|
||||
document.getElementById('rp-status-msg').textContent = '红包已过期。';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 异步拉取服务端最新状态(实时刷新剩余份数)
|
||||
fetch(`/red-packet/${envelopeId}/status`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
|
||||
// 更新剩余份数显示
|
||||
document.getElementById('rp-remaining').textContent = data.remaining_count;
|
||||
|
||||
const claimBtn = document.getElementById('rp-claim-btn');
|
||||
const statusMsg = document.getElementById('rp-status-msg');
|
||||
|
||||
// 若已过期
|
||||
if (data.is_expired || data.envelope_status === 'expired') {
|
||||
clearInterval(_rpTimer);
|
||||
claimBtn.disabled = true;
|
||||
claimBtn.textContent = '礼包已过期';
|
||||
statusMsg.style.color = '#9ca3af';
|
||||
statusMsg.textContent = '红包已过期。';
|
||||
return;
|
||||
}
|
||||
|
||||
// 若已抢完
|
||||
if (data.remaining_count <= 0 || data.envelope_status === 'completed') {
|
||||
clearInterval(_rpTimer);
|
||||
claimBtn.disabled = true;
|
||||
claimBtn.textContent = '已全部抢完';
|
||||
statusMsg.style.color = '#9ca3af';
|
||||
statusMsg.textContent = '😊 礼包已全部被抢完啦!';
|
||||
return;
|
||||
}
|
||||
|
||||
// 若本人已领取
|
||||
if (data.has_claimed) {
|
||||
claimBtn.disabled = true;
|
||||
claimBtn.textContent = '您已领取';
|
||||
statusMsg.style.color = '#10b981';
|
||||
statusMsg.textContent = '✅ 您已成功领取本次礼包!';
|
||||
}
|
||||
})
|
||||
.catch(() => {}); // 静默忽略网络错误,不影响弹窗展示
|
||||
};
|
||||
|
||||
// ── 关闭红包弹窗 ─────────────────────────────────
|
||||
window.closeRedPacketModal = function() {
|
||||
document.getElementById('red-packet-modal').style.display = 'none';
|
||||
clearInterval(_rpTimer);
|
||||
};
|
||||
|
||||
// 点击遮罩关闭
|
||||
document.getElementById('red-packet-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeRedPacketModal();
|
||||
});
|
||||
|
||||
// ── 抢红包 ──────────────────────────────────────
|
||||
/**
|
||||
* 用户点击「立即抢红包」,调用后端 claim 接口。
|
||||
*/
|
||||
window.claimRedPacket = async function() {
|
||||
if (!_rpEnvelopeId) return;
|
||||
|
||||
const btn = document.getElementById('rp-claim-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '抢包中…';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/red-packet/${_rpEnvelopeId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_id: window.chatContext.roomId
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const statusEl = document.getElementById('rp-status-msg');
|
||||
if (res.ok && data.status === 'success') {
|
||||
_rpClaimed = true;
|
||||
btn.textContent = '🎉 已抢到!';
|
||||
statusEl.style.color = '#16a34a';
|
||||
statusEl.textContent = `恭喜!您抢到了 ${data.amount} 金币!`;
|
||||
|
||||
// 弹出全局 Toast
|
||||
window.chatToast.show({
|
||||
title: '🧧 礼包到账',
|
||||
message: `恭喜您抢到了礼包 <b>${data.amount}</b> 金币!`,
|
||||
icon: '🧧',
|
||||
color: '#dc2626',
|
||||
duration: 8000,
|
||||
});
|
||||
|
||||
// 3 秒后自动关闭弹窗
|
||||
setTimeout(() => closeRedPacketModal(), 3000);
|
||||
} else {
|
||||
statusEl.style.color = '#dc2626';
|
||||
statusEl.textContent = data.message || '抢包失败';
|
||||
// 若是「已领过」或「已抢完」则禁用按钮,否则解除禁用以重试
|
||||
if (data.message && (data.message.includes('已经领过') || data.message.includes('已被抢完') ||
|
||||
data.message.includes('已抢完'))) {
|
||||
btn.textContent = '已参与';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🧧 立即抢红包';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('rp-status-msg').textContent = '网络异常,请重试';
|
||||
document.getElementById('rp-status-msg').style.color = '#dc2626';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🧧 立即抢红包';
|
||||
}
|
||||
};
|
||||
|
||||
// ── 更新领取名单(被 WS 触发调用)───────────────
|
||||
/**
|
||||
* 收到「系统传音」中的领取播报时,同步更新弹窗内的名单与剩余数。
|
||||
*
|
||||
* @param {string} username 领取者用户名
|
||||
* @param {number} amount 领取金额
|
||||
* @param {number} remaining 剩余份数
|
||||
*/
|
||||
window.updateRedPacketClaimsUI = function(username, amount, remaining) {
|
||||
const remainingEl = document.getElementById('rp-remaining');
|
||||
if (remainingEl) remainingEl.textContent = remaining;
|
||||
|
||||
const listEl = document.getElementById('rp-claims-list');
|
||||
const itemsEl = document.getElementById('rp-claims-items');
|
||||
if (!listEl || !itemsEl) return;
|
||||
|
||||
listEl.style.display = 'block';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'rp-claim-item';
|
||||
item.innerHTML = `<span>${username}</span><span>+${amount} 金币</span>`;
|
||||
itemsEl.prepend(item);
|
||||
|
||||
// 若已全部领完,更新按钮状态
|
||||
if (remaining <= 0) {
|
||||
const btn = document.getElementById('rp-claim-btn');
|
||||
if (btn && !_rpClaimed) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '礼包已被抢完!';
|
||||
}
|
||||
clearInterval(_rpTimer);
|
||||
// 3 秒后自动关闭
|
||||
setTimeout(() => closeRedPacketModal(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// ── WebSocket 监听 red-packet.sent ───────────────
|
||||
/**
|
||||
* 等待 Echo 就绪后注册 red-packet.sent 事件监听,
|
||||
* 每次收到新红包时弹出红包卡片弹窗。
|
||||
*/
|
||||
function setupRedPacketListener() {
|
||||
if (!window.Echo || !window.chatContext) {
|
||||
setTimeout(setupRedPacketListener, 500);
|
||||
return;
|
||||
}
|
||||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||||
.listen('.red-packet.sent', (e) => {
|
||||
// 收到红包事件,弹出卡片(type 决定金币/经验配色)
|
||||
showRedPacketModal(
|
||||
e.envelope_id,
|
||||
e.sender_username,
|
||||
e.total_amount,
|
||||
e.total_count,
|
||||
e.expire_seconds,
|
||||
e.type || 'gold',
|
||||
);
|
||||
});
|
||||
console.log('RedPacketSent 监听器已注册');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', setupRedPacketListener);
|
||||
|
||||
})(); // end IIFE
|
||||
</script>
|
||||
|
||||
@@ -179,6 +179,11 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen');
|
||||
Route::post('/command/effect', [AdminCommandController::class, 'effect'])->name('command.effect');
|
||||
|
||||
// ---- 礼包红包(superlevel 发包 / 所有登录用户可抢)----
|
||||
Route::post('/command/red-packet/send', [\App\Http\Controllers\RedPacketController::class, 'send'])->name('command.red_packet.send');
|
||||
Route::get('/red-packet/{envelopeId}/status', [\App\Http\Controllers\RedPacketController::class, 'status'])->name('red_packet.status');
|
||||
Route::post('/red-packet/{envelopeId}/claim', [\App\Http\Controllers\RedPacketController::class, 'claim'])->name('red_packet.claim');
|
||||
|
||||
// ---- 商店(购买特效卡/改名卡)----
|
||||
Route::get('/shop/items', [\App\Http\Controllers\ShopController::class, 'items'])->name('shop.items');
|
||||
Route::post('/shop/buy', [\App\Http\Controllers\ShopController::class, 'buy'])->name('shop.buy');
|
||||
|
||||
Reference in New Issue
Block a user