prepareRun($scheduleService); if (! $run) { return; } // 获取在线用户(满足目标用户条件),每一轮都按触发当时的在线状态重新计算。 $onlineIds = $this->getEligibleOnlineUsers($run); if (empty($onlineIds)) { $run->update(['status' => 'completed', 'audience_count' => 0]); return; } // 按本批次快照中的 max_claimants 限制人数。 if ($run->max_claimants > 0 && count($onlineIds) > $run->max_claimants) { shuffle($onlineIds); $onlineIds = array_slice($onlineIds, 0, $run->max_claimants); } // 计算每人的待领取金额。 $amounts = $this->distributeAmounts($run, count($onlineIds)); DB::transaction(function () use ($run, $onlineIds, $amounts): void { $claims = []; foreach ($onlineIds as $i => $userId) { $claims[] = [ 'event_id' => $run->holiday_event_id, 'run_id' => $run->id, 'user_id' => $userId, 'amount' => $amounts[$i] ?? 0, 'claimed_at' => null, ]; } // 一次性生成本轮全部待领取记录,claimed_at 默认为 null。 HolidayClaim::insert($claims); $run->update(['audience_count' => count($claims)]); }); // 广播本轮发放批次,前端将基于 run_id 领取。 broadcast(new HolidayEventStarted($run->fresh())); // 向聊天室追加系统公告,提醒用户点击弹窗领取。 $this->pushSystemMessage($run, count($onlineIds), $chatState); } /** * 生成批次并推进模板到下一次触发时间。 */ private function prepareRun(HolidayEventScheduleService $scheduleService): ?HolidayEventRun { /** @var HolidayEventRun|null $run */ $run = DB::transaction(function () use ($scheduleService): ?HolidayEventRun { /** @var HolidayEvent|null $event */ $event = HolidayEvent::query() ->whereKey($this->event->id) ->lockForUpdate() ->first(); if (! $event || ! $event->enabled || $event->status === 'cancelled') { return null; } $now = now(); $scheduledFor = $this->manual ? $now->copy() : $event->send_at; if (! $this->manual) { // 定时触发只允许处理真正到期且仍处于 pending 的模板。 if ($event->status !== 'pending' || $scheduledFor === null || $scheduledFor->isFuture()) { return null; } $nextSendAt = $scheduleService->advanceAfterTrigger($event); $event->update([ 'send_at' => $nextSendAt, 'status' => $nextSendAt ? 'pending' : 'completed', 'triggered_at' => $now, 'expires_at' => $now->copy()->addMinutes($event->expire_minutes), 'claimed_count' => 0, 'claimed_amount' => 0, ]); } else { // 手动立即触发只更新最后触发信息,不改动既有 send_at 锚点。 $event->update([ 'triggered_at' => $now, 'expires_at' => $now->copy()->addMinutes($event->expire_minutes), ]); } return HolidayEventRun::query()->create([ 'holiday_event_id' => $event->id, 'event_name' => $event->name, 'event_description' => $event->description, 'total_amount' => $event->total_amount, 'max_claimants' => $event->max_claimants, 'distribute_type' => $event->distribute_type, 'min_amount' => $event->min_amount, 'max_amount' => $event->max_amount, 'fixed_amount' => $event->fixed_amount, 'target_type' => $event->target_type, 'target_value' => $event->target_value, 'repeat_type' => $event->repeat_type, 'scheduled_for' => $scheduledFor, 'triggered_at' => $now, 'expires_at' => $now->copy()->addMinutes($event->expire_minutes), 'status' => 'active', 'audience_count' => 0, 'claimed_count' => 0, 'claimed_amount' => 0, ]); }); return $run; } /** * 获取满足当前批次条件的在线用户 ID 列表。 * * @return array */ private function getEligibleOnlineUsers(HolidayEventRun $run): array { try { $users = Redis::hgetall('room:1:users'); if (empty($users)) { return []; } $ids = []; $fallbackUsernames = []; foreach ($users as $username => $jsonInfo) { $info = json_decode($jsonInfo, true); if (isset($info['user_id'])) { $ids[] = (int) $info['user_id']; continue; } $fallbackUsernames[] = $username; } if (! empty($fallbackUsernames)) { $fallbackIds = User::query() ->whereIn('username', $fallbackUsernames) ->pluck('id') ->map(fn ($id): int => (int) $id) ->all(); $ids = array_merge($ids, $fallbackIds); } $ids = array_values(array_unique($ids)); // 目标用户范围以当前批次快照为准,避免模板后续编辑影响本轮名单。 return match ($run->target_type) { 'vip' => User::whereIn('id', $ids)->whereNotNull('vip_level_id')->pluck('id')->map(fn ($id) => (int) $id)->all(), 'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($run->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(), default => $ids, }; } catch (\Throwable) { return []; } } /** * 按分配方式计算每人金额数组。 * * @return array */ private function distributeAmounts(HolidayEventRun $run, int $count): array { if ($count <= 0) { return []; } if ($run->distribute_type === 'fixed') { // 定额模式:每人固定一个金额,优先使用模板快照中的 fixed_amount。 $amount = $run->fixed_amount ?? (int) floor($run->total_amount / $count); return array_fill(0, $count, $amount); } // 随机模式沿用二倍均值算法,保证总金额恰好发完。 $total = $run->total_amount; $min = max(1, $run->min_amount ?? 1); $max = $run->max_amount ?? (int) ceil($total * 2 / $count); $amounts = []; $remaining = $total; for ($i = 0; $i < $count - 1; $i++) { $remainingPeople = $count - $i; $avgDouble = (int) floor($remaining * 2 / $remainingPeople); $cap = max($min, min($max, $avgDouble - 1, $remaining - ($remainingPeople - 1) * $min)); $amounts[] = random_int($min, max($min, $cap)); $remaining -= end($amounts); } $amounts[] = max($min, $remaining); shuffle($amounts); return $amounts; } /** * 向聊天室推送系统公告消息并写入 Redis + 落库。 */ private function pushSystemMessage(HolidayEventRun $run, int $claimCount, ChatStateService $chatState): void { $typeLabel = $run->distribute_type === 'fixed' ? "每人固定 {$run->fixed_amount} 金币" : '随机分配'; $content = "🎊 【{$run->event_name}】节日福利开始啦!总奖池 💰".number_format($run->total_amount) ." 金币,{$typeLabel},共 {$claimCount} 名在线用户可领取!点击弹窗按钮立即领取!"; $msg = [ 'id' => $chatState->nextMessageId(1), 'room_id' => 1, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => $content, 'is_secret' => false, 'font_color' => '#f59e0b', 'action' => '大声宣告', 'sent_at' => now()->toDateTimeString(), ]; $chatState->pushMessage(1, $msg); broadcast(new MessageSent(1, $msg)); SaveMessageJob::dispatch($msg); } }