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; } }