From 6fa42b90d5b07b966bb39f6d9c3f4017a8f9abe2 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 1 Mar 2026 22:20:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E7=AB=99=E9=95=BF?= =?UTF-8?q?=E7=A4=BC=E5=8C=85=E7=B3=BB=E7=BB=9F=EF=BC=88=E9=87=91=E5=B8=81?= =?UTF-8?q?/=E7=BB=8F=E9=AA=8C=E5=8F=8C=E7=B1=BB=E5=9E=8B=EF=BC=89+=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7=E7=BC=96=E8=BE=91=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=94=B6=E7=B4=A7=EF=BC=88=E4=BB=85=20id=3D1=20?= =?UTF-8?q?=E8=B6=85=E7=AE=A1=EF=BC=89=20=E6=96=B0=E5=A2=9E=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=9A=20-=20=E7=A4=BC=E5=8C=85=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=9Asuperlevel=20=E7=AB=99=E9=95=BF=E5=8F=AF=E5=8F=91=2088?= =?UTF-8?q?8=20=E6=95=B0=E9=87=8F=2010=20=E4=BB=BD=E7=A4=BC=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E9=87=91=E5=B8=81/=E7=BB=8F?= =?UTF-8?q?=E9=AA=8C=E5=8F=8C=E7=B1=BB=E5=9E=8B=20-=20=E5=8F=91=E5=8C=85?= =?UTF-8?q?=E5=89=8D=E4=B8=89=E6=8C=89=E9=92=AE=E9=80=89=E6=8B=A9=EF=BC=88?= =?UTF-8?q?=E9=87=91=E5=B8=81=E7=A4=BC=E5=8C=85=20/=20=E7=BB=8F=E9=AA=8C?= =?UTF-8?q?=E7=A4=BC=E5=8C=85=20/=20=E5=8F=96=E6=B6=88=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20chatBanner=20=E5=BC=B9=E7=AA=97=20-=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=AE=A4=E7=B3=BB=E7=BB=9F=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E5=90=AB=E3=80=8C=E7=AB=8B=E5=8D=B3=E6=8A=A2=E5=8C=85=E3=80=8D?= =?UTF-8?q?=E6=8C=89=E9=92=AE=EF=BC=8C=E9=87=91=E5=B8=81=E7=BA=A2=E8=89=B2?= =?UTF-8?q?/=E7=BB=8F=E9=AA=8C=E7=B4=AB=E8=89=B2=E9=85=8D=E8=89=B2?= =?UTF-8?q?=E5=8C=BA=E5=88=86=20-=20WebSocket=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E7=BA=A2=E5=8C=85=E5=BC=B9=E7=AA=97=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E8=87=B3=E6=89=80=E6=9C=89=E5=9C=A8=E7=BA=BF=E7=94=A8?= =?UTF-8?q?=E6=88=B7=20-=20Redis=20LPOP=20=E5=8E=9F=E5=AD=90=E5=88=86?= =?UTF-8?q?=E5=8F=91=20+=20=E6=95=B0=E6=8D=AE=E5=BA=93=20unique=20?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E9=98=B2=E9=87=8D=E9=A2=86=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=AE=89=E5=85=A8=20-=20=E5=BC=B9=E7=AA=97=E6=89=93?= =?UTF-8?q?=E5=BC=80=E8=87=AA=E5=8A=A8=E6=8B=89=E5=8F=96=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=9C=80=E6=96=B0=E7=8A=B6=E6=80=81=EF=BC=88=E5=89=A9?= =?UTF-8?q?=E4=BD=99=E6=95=B0=E9=87=8F/=E5=B7=B2=E9=A2=86/=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E5=AE=9E=E6=97=B6=E5=88=B7=E6=96=B0=EF=BC=89=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20GET=20/red-packet/{id}/status=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20CurrencySource::RED=5FPACKET=5FRECV=20/=20RED=5FPAC?= =?UTF-8?q?KET=5FRECV=5FEXP=20=E6=9E=9A=E4=B8=BE=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=9A=20-=20=E5=90=8E=E5=8F=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=BC=96=E8=BE=91/=E5=BC=BA=E6=9D=80=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E4=BB=85=20id=3D1=20=E8=B6=85=E7=AE=A1=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=EF=BC=88=E5=89=8D=E7=AB=AF=E9=9A=90=E8=97=8F=20+=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20403=20=E5=8F=8C=E9=87=8D=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Enums/CurrencySource.php | 8 + app/Events/RedPacketClaimed.php | 64 ++ app/Events/RedPacketSent.php | 75 +++ .../Admin/UserManagerController.php | 17 +- app/Http/Controllers/RedPacketController.php | 365 +++++++++++ app/Models/RedPacketClaim.php | 50 ++ app/Models/RedPacketEnvelope.php | 80 +++ ...2100_create_red_packet_envelopes_table.php | 66 ++ ...add_type_to_red_packet_envelopes_table.php | 39 ++ resources/views/admin/users/index.blade.php | 55 +- .../views/chat/partials/input-bar.blade.php | 3 + .../views/chat/partials/scripts.blade.php | 614 ++++++++++++++++++ routes/web.php | 5 + 13 files changed, 1414 insertions(+), 27 deletions(-) create mode 100644 app/Events/RedPacketClaimed.php create mode 100644 app/Events/RedPacketSent.php create mode 100644 app/Http/Controllers/RedPacketController.php create mode 100644 app/Models/RedPacketClaim.php create mode 100644 app/Models/RedPacketEnvelope.php create mode 100644 database/migrations/2026_03_01_212100_create_red_packet_envelopes_table.php create mode 100644 database/migrations/2026_03_01_213755_add_type_to_red_packet_envelopes_table.php diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 2d545a0..a570fd2 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -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 => '领取礼包红包(经验)', }; } } diff --git a/app/Events/RedPacketClaimed.php b/app/Events/RedPacketClaimed.php new file mode 100644 index 0000000..ecec58c --- /dev/null +++ b/app/Events/RedPacketClaimed.php @@ -0,0 +1,64 @@ + + */ + public function broadcastOn(): array + { + return [new PrivateChannel('user.'.$this->claimer->id)]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'envelope_id' => $this->envelopeId, + 'amount' => $this->amount, + 'message' => "🧧 成功抢到 {$this->amount} 金币礼包!", + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'red-packet.claimed'; + } +} diff --git a/app/Events/RedPacketSent.php b/app/Events/RedPacketSent.php new file mode 100644 index 0000000..ca1eab1 --- /dev/null +++ b/app/Events/RedPacketSent.php @@ -0,0 +1,75 @@ + + */ + public function broadcastOn(): array + { + return [new PresenceChannel('room.'.$this->roomId)]; + } + + /** + * 广播数据:前端渲染红包弹窗所需字段。 + * + * @return array + */ + 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'; + } +} diff --git a/app/Http/Controllers/Admin/UserManagerController.php b/app/Http/Controllers/Admin/UserManagerController.php index c413b58..25800cf 100644 --- a/app/Http/Controllers/Admin/UserManagerController.php +++ b/app/Http/Controllers/Admin/UserManagerController.php @@ -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, '权限不足:无法删除同级或高级账号!'); diff --git a/app/Http/Controllers/RedPacketController.php b/app/Http/Controllers/RedPacketController.php new file mode 100644 index 0000000..86db6e8 --- /dev/null +++ b/app/Http/Controllers/RedPacketController.php @@ -0,0 +1,365 @@ +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 = ''; + + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统公告', + 'to_user' => '', + 'content' => "🧧 {$user->username} 发出了一个 ".self::TOTAL_AMOUNT." {$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' => "🧧 {$user->username} 抢到了 {$amount} {$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; + } +} diff --git a/app/Models/RedPacketClaim.php b/app/Models/RedPacketClaim.php new file mode 100644 index 0000000..8a3023d --- /dev/null +++ b/app/Models/RedPacketClaim.php @@ -0,0 +1,50 @@ + 'datetime', + ]; + } + + /** + * 关联红包主表。 + * + * @return BelongsTo + */ + public function envelope(): BelongsTo + { + return $this->belongsTo(RedPacketEnvelope::class, 'envelope_id'); + } +} diff --git a/app/Models/RedPacketEnvelope.php b/app/Models/RedPacketEnvelope.php new file mode 100644 index 0000000..14eef18 --- /dev/null +++ b/app/Models/RedPacketEnvelope.php @@ -0,0 +1,80 @@ + 'datetime', + ]; + } + + /** + * 关联领取记录。 + * + * @return HasMany + */ + 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); + } +} diff --git a/database/migrations/2026_03_01_212100_create_red_packet_envelopes_table.php b/database/migrations/2026_03_01_212100_create_red_packet_envelopes_table.php new file mode 100644 index 0000000..a7027e8 --- /dev/null +++ b/database/migrations/2026_03_01_212100_create_red_packet_envelopes_table.php @@ -0,0 +1,66 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_01_213755_add_type_to_red_packet_envelopes_table.php b/database/migrations/2026_03_01_213755_add_type_to_red_packet_envelopes_table.php new file mode 100644 index 0000000..8a9cac3 --- /dev/null +++ b/database/migrations/2026_03_01_213755_add_type_to_red_packet_envelopes_table.php @@ -0,0 +1,39 @@ +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'); + }); + } +}; diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php index b15cb37..201140b 100644 --- a/resources/views/admin/users/index.blade.php +++ b/resources/views/admin/users/index.blade.php @@ -130,32 +130,37 @@ - - -
- @csrf @method('DELETE') - -
+ +
+ @csrf @method('DELETE') + +
+ @else + 仅超管可操作 + @endif @endforeach diff --git a/resources/views/chat/partials/input-bar.blade.php b/resources/views/chat/partials/input-bar.blade.php index 9ee977d..6c44bb3 100644 --- a/resources/views/chat/partials/input-bar.blade.php +++ b/resources/views/chat/partials/input-bar.blade.php @@ -95,6 +95,9 @@ + {{-- 全屏特效按钮组(仅管理员可见) --}} +
+ {{-- 领取名单 --}} + + + + + + diff --git a/routes/web.php b/routes/web.php index a5d929c..ef39e60 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');