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');