$data */ public function createEvent(User $operator, array $data): BaccaratLossCoverEvent { $startsAt = Carbon::parse((string) $data['starts_at']); $endsAt = Carbon::parse((string) $data['ends_at']); if ($this->hasOverlap($startsAt, $endsAt)) { throw new \RuntimeException('活动时间段与其他百家乐买单活动重叠,请调整后再试。'); } $event = BaccaratLossCoverEvent::create([ 'title' => (string) $data['title'], 'description' => $data['description'] ?: null, 'status' => 'scheduled', 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'claim_deadline_at' => Carbon::parse((string) $data['claim_deadline_at']), 'created_by_user_id' => $operator->id, ]); // 如果开始时间已到,则立即激活活动,避免用户等待下一次定时扫描。 if ($event->starts_at->lte(now())) { $this->activateEvent($event); } return $event->fresh(['creator', 'closer']); } /** * 手动结束或取消活动。 */ public function forceCloseEvent(BaccaratLossCoverEvent $event, User $operator): BaccaratLossCoverEvent { if ($event->status === 'cancelled' || $event->status === 'completed') { return $event; } // 未开始前手动关闭视为取消,保留完整开启档案但不参与结算。 if ($event->status === 'scheduled' && $event->starts_at->isFuture()) { $event->update([ 'status' => 'cancelled', 'closed_by_user_id' => $operator->id, ]); return $event->fresh(['creator', 'closer']); } // 已开始的活动会被立即截断结束时间,然后走正常的结算/领取流转。 $event->update([ 'ends_at' => now(), 'closed_by_user_id' => $operator->id, ]); $this->transitionAfterEnd($event->fresh()); return $event->fresh(['creator', 'closer']); } /** * 定时推进所有买单活动的生命周期。 */ public function tick(): void { $this->activateDueEvents(); $this->closeDueActiveEvents(); $this->finalizeSettlementPendingEvents(); $this->expireClaimableEvents(); } /** * 获取某个下注时间点命中的活动。 * * 这里按管理员设定的开始/结束时间窗口判断, * 不强依赖后台状态已经及时切到 active, * 这样刚到开始时间的活动也能立即参与买单判定。 */ public function findEventForBetTime(?Carbon $betTime = null): ?BaccaratLossCoverEvent { $betTime = $betTime ?? now(); return BaccaratLossCoverEvent::query() ->whereNotIn('status', ['cancelled', 'completed']) ->where('starts_at', '<=', $betTime) ->where('ends_at', '>=', $betTime) ->orderByDesc('id') ->first(); } /** * 在下注成功后登记用户的活动参与记录。 */ public function registerBet(BaccaratBet $bet): void { if (! $bet->loss_cover_event_id) { return; } // 首次命中活动的用户会创建聚合记录,并计入活动参与人数。 $record = BaccaratLossCoverRecord::query()->firstOrCreate( [ 'event_id' => $bet->loss_cover_event_id, 'user_id' => $bet->user_id, ], [ 'claim_status' => 'not_eligible', ], ); if ($record->wasRecentlyCreated) { BaccaratLossCoverEvent::query() ->where('id', $bet->loss_cover_event_id) ->increment('participant_count'); } // 每一笔命中活动的下注都要累加到账户活动统计中。 $record->increment('total_bet_amount', $bet->amount); } /** * 在百家乐结算后同步更新活动用户记录。 */ public function registerSettlement(BaccaratBet $bet): void { if (! $bet->loss_cover_event_id) { return; } $record = BaccaratLossCoverRecord::query()->firstOrCreate( [ 'event_id' => $bet->loss_cover_event_id, 'user_id' => $bet->user_id, ], [ 'claim_status' => 'not_eligible', ], ); // 中奖只记录赔付统计,不影响补偿资格。 if ($bet->status === 'won') { $record->increment('total_win_payout', $bet->payout); return; } if ($bet->status !== 'lost') { return; } // 输掉的金额就是后续可领取的补偿金额。 $record->increment('total_loss_amount', $bet->amount); $record->increment('compensation_amount', $bet->amount); $record->update(['claim_status' => 'pending']); BaccaratLossCoverEvent::query() ->where('id', $bet->loss_cover_event_id) ->increment('total_loss_amount', $bet->amount); } /** * 用户领取某次活动的补偿金币。 * * @return array{ok: bool, message: string, amount?: int} */ public function claim(BaccaratLossCoverEvent $event, User $user): array { if (! $event->isClaimable()) { return ['ok' => false, 'message' => '当前活动暂未开放领取,或已超过领取时间。']; } $result = DB::transaction(function () use ($event, $user): array { $record = BaccaratLossCoverRecord::query() ->where('event_id', $event->id) ->where('user_id', $user->id) ->lockForUpdate() ->first(); if (! $record || $record->compensation_amount <= 0) { return ['ok' => false, 'message' => '您在本次活动中暂无可领取补偿。']; } if ($record->claim_status === 'claimed') { return ['ok' => false, 'message' => '本次活动补偿您已经领取过了。']; } if ($record->claim_status === 'expired') { return ['ok' => false, 'message' => '本次活动补偿已过期,无法领取。']; } $amount = (int) $record->compensation_amount; // 领取成功时必须统一走金币服务,确保 user_currency_logs 自动落账。 $this->currency->change( $user, 'gold', $amount, CurrencySource::BACCARAT_LOSS_COVER_CLAIM, "百家乐买单活动 #{$event->id} 领取补偿", ); $record->update([ 'claim_status' => 'claimed', 'claimed_amount' => $amount, 'claimed_at' => now(), ]); $event->increment('total_claimed_amount', $amount); // 所有待领取记录都被领完后,可提前结束活动,方便前台展示最终状态。 if (! BaccaratLossCoverRecord::query()->where('event_id', $event->id)->where('claim_status', 'pending')->exists()) { $event->update(['status' => 'completed']); } return [ 'ok' => true, 'message' => "已成功领取 {$amount} 金币补偿!", 'amount' => $amount, ]; }); // 领取成功后,需要向聊天室广播一条同款百家乐风格消息,方便其他人快速领取。 if (($result['ok'] ?? false) === true && isset($result['amount'])) { $this->pushClaimMessage($event->fresh(), $user, (int) $result['amount']); } return $result; } /** * 扫描并激活到时的活动。 */ public function activateDueEvents(): void { BaccaratLossCoverEvent::query() ->where('status', 'scheduled') ->where('starts_at', '<=', now()) ->orderBy('starts_at') ->get() ->each(function (BaccaratLossCoverEvent $event): void { $this->activateEvent($event); }); } /** * 扫描已到结束时间的进行中活动。 */ public function closeDueActiveEvents(): void { BaccaratLossCoverEvent::query() ->where('status', 'active') ->where('ends_at', '<=', now()) ->orderBy('ends_at') ->get() ->each(function (BaccaratLossCoverEvent $event): void { $this->transitionAfterEnd($event); }); } /** * 扫描等待结算完成的活动并尝试开放领取。 */ public function finalizeSettlementPendingEvents(): void { BaccaratLossCoverEvent::query() ->where('status', 'settlement_pending') ->orderBy('ends_at') ->get() ->each(function (BaccaratLossCoverEvent $event): void { $this->transitionAfterEnd($event); }); } /** * 扫描补偿领取过期的活动并收尾。 */ public function expireClaimableEvents(): void { BaccaratLossCoverEvent::query() ->where('status', 'claimable') ->where('claim_deadline_at', '<=', now()) ->orderBy('claim_deadline_at') ->get() ->each(function (BaccaratLossCoverEvent $event): void { // 超过领取时间后,未领取的记录统一标记为过期。 BaccaratLossCoverRecord::query() ->where('event_id', $event->id) ->where('claim_status', 'pending') ->update(['claim_status' => 'expired']); $event->update(['status' => 'completed']); }); } /** * 判断指定时间窗是否与历史或进行中的活动冲突。 */ public function hasOverlap(Carbon $startsAt, Carbon $endsAt): bool { return BaccaratLossCoverEvent::query() ->where('status', '!=', 'cancelled') ->where('starts_at', '<', $endsAt) ->where('ends_at', '>', $startsAt) ->exists(); } /** * 激活单个活动并发送开始通知。 */ private function activateEvent(BaccaratLossCoverEvent $event): void { $updated = BaccaratLossCoverEvent::query() ->where('id', $event->id) ->where('status', 'scheduled') ->update(['status' => 'active']); if (! $updated) { return; } $event = $event->fresh(['creator']); // 只发送一次开始通知,避免调度重复触发。 if ($event && $event->started_notice_sent_at === null) { $creatorName = $event->creator?->username ?? '管理员'; $content = "🎉 【{$event->title}】活动开始啦!开启人:{$creatorName},时间:{$event->starts_at?->format('m-d H:i')} ~ {$event->ends_at?->format('m-d H:i')}。活动期间参与百家乐,赢的归个人,输的活动结束后可领取补偿。"; $this->pushRoomMessage($content, '#16a34a'); $event->update(['started_notice_sent_at' => now()]); } } /** * 根据活动内是否仍有未结算下注,推进结束后的状态。 */ private function transitionAfterEnd(BaccaratLossCoverEvent $event): void { if (! in_array($event->status, ['active', 'settlement_pending'], true)) { return; } $hasPendingBet = BaccaratBet::query() ->where('loss_cover_event_id', $event->id) ->where('status', 'pending') ->exists(); // 仍有活动内下注未开奖时,先进入等待结算状态。 if ($hasPendingBet) { $event->update(['status' => 'settlement_pending']); return; } // 全部结算完成后,活动正式进入可领取状态。 $compensableCount = BaccaratLossCoverRecord::query() ->where('event_id', $event->id) ->where('claim_status', 'pending') ->count(); $event->update([ 'status' => $compensableCount > 0 ? 'claimable' : 'completed', 'compensable_user_count' => $compensableCount, ]); if ($event->ended_notice_sent_at !== null) { return; } if ($compensableCount > 0) { $button = ''; $content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 {$compensableCount} 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}。{$button}"; } else { $content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。"; } $this->pushRoomMessage($content, '#7c3aed'); $event->update(['ended_notice_sent_at' => now()]); // 活动开放领取后,为 AI小班长补发一次自动领取任务。 $this->dispatchAiAutoClaimJob($event->fresh()); } /** * 向房间广播一条系统公告消息。 */ private function pushRoomMessage(string $content, string $fontColor): void { $message = [ 'id' => $this->chatState->nextMessageId(1), 'room_id' => 1, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => $content, 'is_secret' => false, 'font_color' => $fontColor, 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage(1, $message); broadcast(new MessageSent(1, $message)); SaveMessageJob::dispatch($message); } /** * 在用户领取补偿成功后,向聊天室广播领取播报。 */ private function pushClaimMessage(?BaccaratLossCoverEvent $event, User $user, int $amount): void { if (! $event) { return; } $formattedAmount = number_format($amount); $button = $event->status === 'claimable' ? ' ' : ''; // 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。 $content = "🌟 🎲 {$user->username} 领取了 {$formattedAmount} 金币补偿!✨{$button}"; $message = [ 'id' => $this->chatState->nextMessageId(1), 'room_id' => 1, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => $content, 'is_secret' => false, 'font_color' => '#d97706', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage(1, $message); broadcast(new MessageSent(1, $message)); SaveMessageJob::dispatch($message); } /** * 在活动可领取后,为 AI小班长派发自动领取补偿任务。 */ private function dispatchAiAutoClaimJob(?BaccaratLossCoverEvent $event): void { if (! $event || $event->status !== 'claimable') { return; } $aiUserId = User::query()->where('username', 'AI小班长')->value('id'); if (! $aiUserId) { return; } $hasPendingRecord = BaccaratLossCoverRecord::query() ->where('event_id', $event->id) ->where('user_id', $aiUserId) ->where('claim_status', 'pending') ->where('compensation_amount', '>', 0) ->exists(); // 只有 AI小班长确实存在待领取补偿时,才派发自动领取任务。 if (! $hasPendingRecord) { return; } AiClaimBaccaratLossCoverJob::dispatch($event->id); } }