Files
chatroom/app/Services/BaccaratLossCoverService.php
2026-04-12 22:42:32 +08:00

500 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:百家乐买单活动服务
*
* 统一处理活动创建、生命周期推进、下注关联、
* 结算累计补偿、用户领取补偿以及聊天室通知广播。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\AiClaimBaccaratLossCoverJob;
use App\Jobs\SaveMessageJob;
use App\Models\BaccaratBet;
use App\Models\BaccaratLossCoverEvent;
use App\Models\BaccaratLossCoverRecord;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class BaccaratLossCoverService
{
/**
* 注入聊天室状态与金币服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currency,
) {}
/**
* 创建新的百家乐买单活动。
*
* @param array<string, mixed> $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}】活动开始啦!开启人:<b>{$creatorName}</b>,时间:{$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 = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>';
$content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$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'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>'
: '';
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
$content = "🌟 🎲 <b>{$user->username}</b> 领取了 <b>{$formattedAmount}</b> 金币补偿!✨{$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);
}
}