Add baccarat loss cover activity

This commit is contained in:
2026-04-11 23:27:29 +08:00
parent dd9a8c5db8
commit e43dceab2c
22 changed files with 1898 additions and 5 deletions
+422
View File
@@ -0,0 +1,422 @@
<?php
/**
* 文件功能:百家乐买单活动服务
*
* 统一处理活动创建、生命周期推进、下注关联、
* 结算累计补偿、用户领取补偿以及聊天室通知广播。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
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();
}
/**
* 获取某个下注时间点命中的活动。
*/
public function findEventForBetTime(?Carbon $betTime = null): ?BaccaratLossCoverEvent
{
$betTime = $betTime ?? now();
return BaccaratLossCoverEvent::query()
->whereIn('status', ['active', 'settlement_pending', 'claimable'])
->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' => '当前活动暂未开放领取,或已超过领取时间。'];
}
return 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,
];
});
}
/**
* 扫描并激活到时的活动。
*/
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()]);
}
/**
* 向房间广播一条系统公告消息。
*/
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);
}
}