500 lines
17 KiB
PHP
500 lines
17 KiB
PHP
<?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);
|
||
}
|
||
}
|