Add baccarat loss cover activity
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user