From e43dceab2cc14a01642016018c61e8e08617d1da Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 11 Apr 2026 23:27:29 +0800 Subject: [PATCH] Add baccarat loss cover activity --- app/Enums/CurrencySource.php | 4 + .../BaccaratLossCoverEventController.php | 62 +++ app/Http/Controllers/BaccaratController.php | 14 +- .../BaccaratLossCoverController.php | 112 +++++ .../StoreBaccaratLossCoverEventRequest.php | 55 +++ app/Jobs/CloseBaccaratRoundJob.php | 9 +- app/Models/BaccaratBet.php | 12 +- app/Models/BaccaratLossCoverEvent.php | 119 +++++ app/Models/BaccaratLossCoverRecord.php | 82 ++++ app/Services/BaccaratLossCoverService.php | 422 ++++++++++++++++++ .../BaccaratLossCoverEventFactory.php | 46 ++ .../BaccaratLossCoverRecordFactory.php | 39 ++ ...reate_baccarat_loss_cover_events_table.php | 61 +++ ...eate_baccarat_loss_cover_records_table.php | 54 +++ ...s_cover_columns_to_baccarat_bets_table.php | 42 ++ resources/views/chat/frame.blade.php | 1 + .../games/baccarat-loss-cover-panel.blade.php | 288 ++++++++++++ .../chat/partials/games/game-hall.blade.php | 57 +++ .../chat/partials/layout/input-bar.blade.php | 205 +++++++++ routes/console.php | 5 + routes/web.php | 11 + .../BaccaratLossCoverControllerTest.php | 203 +++++++++ 22 files changed, 1898 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/Admin/BaccaratLossCoverEventController.php create mode 100644 app/Http/Controllers/BaccaratLossCoverController.php create mode 100644 app/Http/Requests/StoreBaccaratLossCoverEventRequest.php create mode 100644 app/Models/BaccaratLossCoverEvent.php create mode 100644 app/Models/BaccaratLossCoverRecord.php create mode 100644 app/Services/BaccaratLossCoverService.php create mode 100644 database/factories/BaccaratLossCoverEventFactory.php create mode 100644 database/factories/BaccaratLossCoverRecordFactory.php create mode 100644 database/migrations/2026_04_11_230622_create_baccarat_loss_cover_events_table.php create mode 100644 database/migrations/2026_04_11_230622_create_baccarat_loss_cover_records_table.php create mode 100644 database/migrations/2026_04_11_230644_add_loss_cover_columns_to_baccarat_bets_table.php create mode 100644 resources/views/chat/partials/games/baccarat-loss-cover-panel.blade.php create mode 100644 tests/Feature/BaccaratLossCoverControllerTest.php diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 8bf92a6..6529092 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -84,6 +84,9 @@ enum CurrencySource: string /** 百家乐中奖赔付(收入金币,含本金返还) */ case BACCARAT_WIN = 'baccarat_win'; + /** 百家乐买单活动补偿领取(收入金币) */ + case BACCARAT_LOSS_COVER_CLAIM = 'baccarat_loss_cover_claim'; + /** 星海小博士随机事件(好运/坏运/经验/金币奖惩) */ case AUTO_EVENT = 'auto_event'; @@ -162,6 +165,7 @@ enum CurrencySource: string self::HOLIDAY_BONUS => '节日福利', self::BACCARAT_BET => '百家乐下注', self::BACCARAT_WIN => '百家乐赢钱', + self::BACCARAT_LOSS_COVER_CLAIM => '百家乐买单活动补偿', self::AUTO_EVENT => '随机事件(星海小博士)', self::SLOT_SPIN => '老虎机转动', self::SLOT_WIN => '老虎机中奖', diff --git a/app/Http/Controllers/Admin/BaccaratLossCoverEventController.php b/app/Http/Controllers/Admin/BaccaratLossCoverEventController.php new file mode 100644 index 0000000..7d77086 --- /dev/null +++ b/app/Http/Controllers/Admin/BaccaratLossCoverEventController.php @@ -0,0 +1,62 @@ +lossCoverService->createEvent($request->user(), $request->validated()); + } catch (\RuntimeException $exception) { + return response()->json([ + 'ok' => false, + 'message' => $exception->getMessage(), + ], 422); + } + + return response()->json([ + 'ok' => true, + 'message' => "活动「{$event->title}」已创建成功。", + 'event_id' => $event->id, + ]); + } + + /** + * 手动结束或取消一场百家乐买单活动。 + */ + public function close(Request $request, BaccaratLossCoverEvent $event): JsonResponse + { + $event = $this->lossCoverService->forceCloseEvent($event, $request->user()); + + return response()->json([ + 'ok' => true, + 'message' => '活动状态已更新。', + 'status' => $event->status, + ]); + } +} diff --git a/app/Http/Controllers/BaccaratController.php b/app/Http/Controllers/BaccaratController.php index 5a4767d..5b40e8e 100644 --- a/app/Http/Controllers/BaccaratController.php +++ b/app/Http/Controllers/BaccaratController.php @@ -19,6 +19,7 @@ use App\Enums\CurrencySource; use App\Models\BaccaratBet; use App\Models\BaccaratRound; use App\Models\GameConfig; +use App\Services\BaccaratLossCoverService; use App\Services\UserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -28,6 +29,7 @@ class BaccaratController extends Controller { public function __construct( private readonly UserCurrencyService $currency, + private readonly BaccaratLossCoverService $lossCoverService, ) {} /** @@ -107,8 +109,9 @@ class BaccaratController extends Controller } $currency = $this->currency; + $lossCoverService = $this->lossCoverService; - return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse { + return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse { // 幂等:同一局只能下一注 $existing = BaccaratBet::query() ->where('round_id', $round->id) @@ -131,15 +134,22 @@ class BaccaratController extends Controller }, ); + // 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。 + $lossCoverEvent = $lossCoverService->findEventForBetTime(now()); + // 写入下注记录 - BaccaratBet::create([ + $bet = BaccaratBet::create([ 'round_id' => $round->id, 'user_id' => $user->id, + 'loss_cover_event_id' => $lossCoverEvent?->id, 'bet_type' => $data['bet_type'], 'amount' => $data['amount'], 'status' => 'pending', ]); + // 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。 + $lossCoverService->registerBet($bet); + // 更新局次汇总统计 $field = 'total_bet_'.$data['bet_type']; $countField = 'bet_count_'.$data['bet_type']; diff --git a/app/Http/Controllers/BaccaratLossCoverController.php b/app/Http/Controllers/BaccaratLossCoverController.php new file mode 100644 index 0000000..5fe2722 --- /dev/null +++ b/app/Http/Controllers/BaccaratLossCoverController.php @@ -0,0 +1,112 @@ +with(['creator:id,username']) + ->whereIn('status', ['active', 'settlement_pending', 'claimable', 'scheduled']) + ->orderByRaw("CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'claimable' THEN 2 WHEN 'scheduled' THEN 3 ELSE 4 END") + ->orderByDesc('starts_at') + ->first(); + + $record = null; + if ($event) { + $record = $event->records()->where('user_id', $request->user()->id)->first(); + } + + return response()->json([ + 'event' => $event ? $this->transformEvent($event, $record) : null, + ]); + } + + /** + * 返回最近的活动列表以及当前用户的领取记录。 + */ + public function history(Request $request): JsonResponse + { + $events = BaccaratLossCoverEvent::query() + ->with(['creator:id,username', 'records' => function ($query) use ($request) { + $query->where('user_id', $request->user()->id); + }]) + ->latest('starts_at') + ->limit(20) + ->get() + ->map(function (BaccaratLossCoverEvent $event) { + $record = $event->records->first(); + + return $this->transformEvent($event, $record); + }); + + return response()->json([ + 'events' => $events, + ]); + } + + /** + * 领取指定活动的补偿金币。 + */ + public function claim(Request $request, BaccaratLossCoverEvent $event): JsonResponse + { + $result = $this->lossCoverService->claim($event, $request->user()); + + return response()->json($result); + } + + /** + * 将活动与个人记录整理为前端更容易消费的结构。 + */ + private function transformEvent(BaccaratLossCoverEvent $event, mixed $record): array + { + return [ + 'id' => $event->id, + 'title' => $event->title, + 'description' => $event->description, + 'status' => $event->status, + 'status_label' => $event->statusLabel(), + 'starts_at' => $event->starts_at?->toIso8601String(), + 'ends_at' => $event->ends_at?->toIso8601String(), + 'claim_deadline_at' => $event->claim_deadline_at?->toIso8601String(), + 'participant_count' => $event->participant_count, + 'compensable_user_count' => $event->compensable_user_count, + 'total_loss_amount' => $event->total_loss_amount, + 'total_claimed_amount' => $event->total_claimed_amount, + 'creator_username' => $event->creator?->username ?? '管理员', + 'my_record' => $record ? [ + 'total_bet_amount' => $record->total_bet_amount, + 'total_win_payout' => $record->total_win_payout, + 'total_loss_amount' => $record->total_loss_amount, + 'compensation_amount' => $record->compensation_amount, + 'claim_status' => $record->claim_status, + 'claim_status_label' => $record->claimStatusLabel(), + 'claimed_amount' => $record->claimed_amount, + 'claimed_at' => $record->claimed_at?->toIso8601String(), + ] : null, + ]; + } +} diff --git a/app/Http/Requests/StoreBaccaratLossCoverEventRequest.php b/app/Http/Requests/StoreBaccaratLossCoverEventRequest.php new file mode 100644 index 0000000..f8ac41b --- /dev/null +++ b/app/Http/Requests/StoreBaccaratLossCoverEventRequest.php @@ -0,0 +1,55 @@ +user() !== null; + } + + /** + * 获取字段校验规则。 + * + * @return array> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:500'], + 'starts_at' => ['required', 'date'], + 'ends_at' => ['required', 'date', 'after:starts_at'], + 'claim_deadline_at' => ['required', 'date', 'after_or_equal:ends_at'], + ]; + } + + /** + * 获取中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'title.required' => '请输入活动标题', + 'starts_at.required' => '请选择活动开始时间', + 'ends_at.required' => '请选择活动结束时间', + 'ends_at.after' => '活动结束时间必须晚于开始时间', + 'claim_deadline_at.required' => '请选择领取截止时间', + 'claim_deadline_at.after_or_equal' => '领取截止时间不能早于活动结束时间', + ]; + } +} diff --git a/app/Jobs/CloseBaccaratRoundJob.php b/app/Jobs/CloseBaccaratRoundJob.php index 797892c..0a7009a 100644 --- a/app/Jobs/CloseBaccaratRoundJob.php +++ b/app/Jobs/CloseBaccaratRoundJob.php @@ -20,6 +20,7 @@ use App\Events\MessageSent; use App\Models\BaccaratBet; use App\Models\BaccaratRound; use App\Models\GameConfig; +use App\Services\BaccaratLossCoverService; use App\Services\ChatStateService; use App\Services\UserCurrencyService; use Illuminate\Contracts\Queue\ShouldQueue; @@ -49,6 +50,7 @@ class CloseBaccaratRoundJob implements ShouldQueue public function handle( UserCurrencyService $currency, ChatStateService $chatState, + BaccaratLossCoverService $lossCoverService, ): void { $round = $this->round->fresh(); @@ -95,7 +97,7 @@ class CloseBaccaratRoundJob implements ShouldQueue $winners = []; $losers = []; - DB::transaction(function () use ($bets, $result, $config, $currency, &$totalPayout, &$winners, &$losers) { + DB::transaction(function () use ($bets, $result, $config, $currency, $lossCoverService, &$totalPayout, &$winners, &$losers) { foreach ($bets as $bet) { /** @var \App\Models\BaccaratBet $bet */ $username = $bet->user->username ?? '匿名'; @@ -103,6 +105,7 @@ class CloseBaccaratRoundJob implements ShouldQueue if ($result === 'kill') { // 庄家收割:全灭无退款 $bet->update(['status' => 'lost', 'payout' => 0]); + $lossCoverService->registerSettlement($bet->fresh()); $losers[] = "{$username}-{$bet->amount}"; if ($username === 'AI小班长') { @@ -126,6 +129,7 @@ class CloseBaccaratRoundJob implements ShouldQueue "百家乐 #{$this->round->id} 押 {$bet->betTypeLabel()} 中奖", ); $totalPayout += $payout; + $lossCoverService->registerSettlement($bet->fresh()); $winners[] = "{$username}+".number_format($payout); if ($username === 'AI小班长') { @@ -133,6 +137,7 @@ class CloseBaccaratRoundJob implements ShouldQueue } } else { $bet->update(['status' => 'lost', 'payout' => 0]); + $lossCoverService->registerSettlement($bet->fresh()); $losers[] = "{$username}-".number_format($bet->amount); if ($username === 'AI小班长') { @@ -227,7 +232,7 @@ class CloseBaccaratRoundJob implements ShouldQueue // 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏) try { - if (!empty($winners) || !empty($losers)) { + if (! empty($winners) || ! empty($losers)) { $wechatService = new \App\Services\WechatBot\WechatNotificationService; $wechatService->notifyBaccaratResult($content); } diff --git a/app/Models/BaccaratBet.php b/app/Models/BaccaratBet.php index 15d44bd..5884de0 100644 --- a/app/Models/BaccaratBet.php +++ b/app/Models/BaccaratBet.php @@ -3,7 +3,7 @@ /** * 文件功能:百家乐下注记录模型 * - * 记录用户在某局中的押注信息和结算状态。 + * 记录用户在某局中的押注信息、结算状态以及关联的买单活动。 * * @author ChatRoom Laravel * @@ -20,6 +20,7 @@ class BaccaratBet extends Model protected $fillable = [ 'round_id', 'user_id', + 'loss_cover_event_id', 'bet_type', 'amount', 'payout', @@ -32,6 +33,7 @@ class BaccaratBet extends Model protected function casts(): array { return [ + 'loss_cover_event_id' => 'integer', 'amount' => 'integer', 'payout' => 'integer', ]; @@ -53,6 +55,14 @@ class BaccaratBet extends Model return $this->belongsTo(User::class); } + /** + * 关联参与的百家乐买单活动。 + */ + public function lossCoverEvent(): BelongsTo + { + return $this->belongsTo(BaccaratLossCoverEvent::class, 'loss_cover_event_id'); + } + /** * 获取押注类型中文名。 */ diff --git a/app/Models/BaccaratLossCoverEvent.php b/app/Models/BaccaratLossCoverEvent.php new file mode 100644 index 0000000..e0199d2 --- /dev/null +++ b/app/Models/BaccaratLossCoverEvent.php @@ -0,0 +1,119 @@ + */ + use HasFactory; + + /** + * 允许批量赋值的字段。 + * + * @var list + */ + protected $fillable = [ + 'title', + 'description', + 'status', + 'starts_at', + 'ends_at', + 'claim_deadline_at', + 'created_by_user_id', + 'closed_by_user_id', + 'started_notice_sent_at', + 'ended_notice_sent_at', + 'participant_count', + 'compensable_user_count', + 'total_loss_amount', + 'total_claimed_amount', + ]; + + /** + * 字段类型转换。 + */ + protected function casts(): array + { + return [ + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'claim_deadline_at' => 'datetime', + 'started_notice_sent_at' => 'datetime', + 'ended_notice_sent_at' => 'datetime', + 'participant_count' => 'integer', + 'compensable_user_count' => 'integer', + 'total_loss_amount' => 'integer', + 'total_claimed_amount' => 'integer', + ]; + } + + /** + * 关联:开启活动的用户。 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + /** + * 关联:结束活动的用户。 + */ + public function closer(): BelongsTo + { + return $this->belongsTo(User::class, 'closed_by_user_id'); + } + + /** + * 关联:活动下的用户聚合记录。 + */ + public function records(): HasMany + { + return $this->hasMany(BaccaratLossCoverRecord::class, 'event_id'); + } + + /** + * 关联:活动下的百家乐下注记录。 + */ + public function bets(): HasMany + { + return $this->hasMany(BaccaratBet::class, 'loss_cover_event_id'); + } + + /** + * 判断活动当前是否允许领取补偿。 + */ + public function isClaimable(): bool + { + return $this->status === 'claimable' + && $this->claim_deadline_at !== null + && $this->claim_deadline_at->isFuture(); + } + + /** + * 返回活动状态中文标签。 + */ + public function statusLabel(): string + { + return match ($this->status) { + 'scheduled' => '未开始', + 'active' => '进行中', + 'settlement_pending' => '等待结算', + 'claimable' => '可领取', + 'completed' => '已结束', + 'cancelled' => '已取消', + default => '未知状态', + }; + } +} diff --git a/app/Models/BaccaratLossCoverRecord.php b/app/Models/BaccaratLossCoverRecord.php new file mode 100644 index 0000000..a7f1fe5 --- /dev/null +++ b/app/Models/BaccaratLossCoverRecord.php @@ -0,0 +1,82 @@ + */ + use HasFactory; + + /** + * 允许批量赋值的字段。 + * + * @var list + */ + protected $fillable = [ + 'event_id', + 'user_id', + 'total_bet_amount', + 'total_win_payout', + 'total_loss_amount', + 'compensation_amount', + 'claim_status', + 'claimed_amount', + 'claimed_at', + ]; + + /** + * 字段类型转换。 + */ + protected function casts(): array + { + return [ + 'total_bet_amount' => 'integer', + 'total_win_payout' => 'integer', + 'total_loss_amount' => 'integer', + 'compensation_amount' => 'integer', + 'claimed_amount' => 'integer', + 'claimed_at' => 'datetime', + ]; + } + + /** + * 关联:所属活动。 + */ + public function event(): BelongsTo + { + return $this->belongsTo(BaccaratLossCoverEvent::class, 'event_id'); + } + + /** + * 关联:所属用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 返回领取状态中文标签。 + */ + public function claimStatusLabel(): string + { + return match ($this->claim_status) { + 'not_eligible' => '无补偿', + 'pending' => '待领取', + 'claimed' => '已领取', + 'expired' => '已过期', + default => '未知状态', + }; + } +} diff --git a/app/Services/BaccaratLossCoverService.php b/app/Services/BaccaratLossCoverService.php new file mode 100644 index 0000000..bb1437c --- /dev/null +++ b/app/Services/BaccaratLossCoverService.php @@ -0,0 +1,422 @@ + $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}】活动开始啦!开启人:{$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()]); + } + + /** + * 向房间广播一条系统公告消息。 + */ + 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); + } +} diff --git a/database/factories/BaccaratLossCoverEventFactory.php b/database/factories/BaccaratLossCoverEventFactory.php new file mode 100644 index 0000000..e1f8360 --- /dev/null +++ b/database/factories/BaccaratLossCoverEventFactory.php @@ -0,0 +1,46 @@ + + */ +class BaccaratLossCoverEventFactory extends Factory +{ + /** + * 定义默认测试数据。 + * + * @return array + */ + public function definition(): array + { + $startsAt = now()->subMinutes(5); + $endsAt = now()->addMinutes(25); + + return [ + 'title' => '你玩游戏我买单', + 'description' => '测试活动说明', + 'status' => 'active', + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + 'claim_deadline_at' => $endsAt->copy()->addHours(24), + 'created_by_user_id' => User::factory(), + 'closed_by_user_id' => null, + 'started_notice_sent_at' => null, + 'ended_notice_sent_at' => null, + 'participant_count' => 0, + 'compensable_user_count' => 0, + 'total_loss_amount' => 0, + 'total_claimed_amount' => 0, + ]; + } +} diff --git a/database/factories/BaccaratLossCoverRecordFactory.php b/database/factories/BaccaratLossCoverRecordFactory.php new file mode 100644 index 0000000..87b7082 --- /dev/null +++ b/database/factories/BaccaratLossCoverRecordFactory.php @@ -0,0 +1,39 @@ + + */ +class BaccaratLossCoverRecordFactory extends Factory +{ + /** + * 定义默认测试数据。 + * + * @return array + */ + public function definition(): array + { + return [ + 'event_id' => BaccaratLossCoverEvent::factory(), + 'user_id' => User::factory(), + 'total_bet_amount' => 1000, + 'total_win_payout' => 0, + 'total_loss_amount' => 500, + 'compensation_amount' => 500, + 'claim_status' => 'pending', + 'claimed_amount' => 0, + 'claimed_at' => null, + ]; + } +} diff --git a/database/migrations/2026_04_11_230622_create_baccarat_loss_cover_events_table.php b/database/migrations/2026_04_11_230622_create_baccarat_loss_cover_events_table.php new file mode 100644 index 0000000..734c67b --- /dev/null +++ b/database/migrations/2026_04_11_230622_create_baccarat_loss_cover_events_table.php @@ -0,0 +1,61 @@ +id(); + $table->string('title', 100)->comment('活动标题'); + $table->string('description', 500)->nullable()->comment('活动说明'); + $table->enum('status', ['scheduled', 'active', 'settlement_pending', 'claimable', 'completed', 'cancelled']) + ->default('scheduled') + ->comment('活动状态'); + $table->dateTime('starts_at')->comment('活动开始时间'); + $table->dateTime('ends_at')->comment('活动结束时间'); + $table->dateTime('claim_deadline_at')->comment('补偿领取截止时间'); + $table->foreignId('created_by_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete() + ->comment('开启人'); + $table->foreignId('closed_by_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete() + ->comment('结束人'); + $table->timestamp('started_notice_sent_at')->nullable()->comment('开始通知发送时间'); + $table->timestamp('ended_notice_sent_at')->nullable()->comment('结束通知发送时间'); + $table->unsignedInteger('participant_count')->default(0)->comment('参与人数'); + $table->unsignedInteger('compensable_user_count')->default(0)->comment('可领取补偿人数'); + $table->unsignedBigInteger('total_loss_amount')->default(0)->comment('活动内用户总输金币'); + $table->unsignedBigInteger('total_claimed_amount')->default(0)->comment('最终已补偿发放金币'); + $table->timestamps(); + + $table->index(['status', 'starts_at'], 'blce_status_starts_idx'); + $table->index(['starts_at', 'ends_at'], 'blce_window_idx'); + }); + } + + /** + * 回滚迁移。 + */ + public function down(): void + { + Schema::dropIfExists('baccarat_loss_cover_events'); + } +}; diff --git a/database/migrations/2026_04_11_230622_create_baccarat_loss_cover_records_table.php b/database/migrations/2026_04_11_230622_create_baccarat_loss_cover_records_table.php new file mode 100644 index 0000000..57fd405 --- /dev/null +++ b/database/migrations/2026_04_11_230622_create_baccarat_loss_cover_records_table.php @@ -0,0 +1,54 @@ +id(); + $table->foreignId('event_id') + ->constrained('baccarat_loss_cover_events') + ->cascadeOnDelete() + ->comment('所属活动 ID'); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete() + ->comment('参与用户 ID'); + $table->unsignedBigInteger('total_bet_amount')->default(0)->comment('活动内累计下注金额'); + $table->unsignedBigInteger('total_win_payout')->default(0)->comment('活动内累计赢钱赔付'); + $table->unsignedBigInteger('total_loss_amount')->default(0)->comment('活动内累计输掉金币'); + $table->unsignedBigInteger('compensation_amount')->default(0)->comment('可领取补偿总额'); + $table->enum('claim_status', ['not_eligible', 'pending', 'claimed', 'expired']) + ->default('not_eligible') + ->comment('领取状态'); + $table->unsignedBigInteger('claimed_amount')->default(0)->comment('已领取补偿金额'); + $table->timestamp('claimed_at')->nullable()->comment('领取时间'); + $table->timestamps(); + + $table->unique(['event_id', 'user_id'], 'uq_blcr_event_user'); + $table->index(['user_id', 'claim_status'], 'blcr_user_claim_status_idx'); + }); + } + + /** + * 回滚迁移。 + */ + public function down(): void + { + Schema::dropIfExists('baccarat_loss_cover_records'); + } +}; diff --git a/database/migrations/2026_04_11_230644_add_loss_cover_columns_to_baccarat_bets_table.php b/database/migrations/2026_04_11_230644_add_loss_cover_columns_to_baccarat_bets_table.php new file mode 100644 index 0000000..311f512 --- /dev/null +++ b/database/migrations/2026_04_11_230644_add_loss_cover_columns_to_baccarat_bets_table.php @@ -0,0 +1,42 @@ +foreignId('loss_cover_event_id') + ->nullable() + ->after('user_id') + ->constrained('baccarat_loss_cover_events') + ->nullOnDelete() + ->comment('参与的买单活动 ID'); + + $table->index(['loss_cover_event_id', 'status'], 'bb_loss_cover_status_idx'); + }); + } + + /** + * 回滚新增字段。 + */ + public function down(): void + { + Schema::table('baccarat_bets', function (Blueprint $table) { + $table->dropIndex('bb_loss_cover_status_idx'); + $table->dropConstrainedForeignId('loss_cover_event_id'); + }); + } +}; diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 082cd0c..97770f9 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -196,6 +196,7 @@ @include('chat.partials.games.red-packet-panel') @include('chat.partials.games.fishing-panel') @include('chat.partials.games.game-hall') + @include('chat.partials.games.baccarat-loss-cover-panel') @include('chat.partials.games.gomoku-panel') @include('chat.partials.games.earn-panel') diff --git a/resources/views/chat/partials/games/baccarat-loss-cover-panel.blade.php b/resources/views/chat/partials/games/baccarat-loss-cover-panel.blade.php new file mode 100644 index 0000000..9ca4e5a --- /dev/null +++ b/resources/views/chat/partials/games/baccarat-loss-cover-panel.blade.php @@ -0,0 +1,288 @@ +{{-- + 文件功能:百家乐买单活动前台弹窗 + + 用于在娱乐大厅中查看当前买单活动、历史活动、 + 以及当前用户在每次活动中的补偿领取状态。 +--}} + + + + diff --git a/resources/views/chat/partials/games/game-hall.blade.php b/resources/views/chat/partials/games/game-hall.blade.php index 583eb05..7ec9ae8 100644 --- a/resources/views/chat/partials/games/game-hall.blade.php +++ b/resources/views/chat/partials/games/game-hall.blade.php @@ -127,6 +127,63 @@ }, btnLabel: (data) => data?.round?.status === 'betting' ? '🎲 立即下注' : '📊 查看详情', }, + { + id: 'baccarat_loss_cover', + name: '🎁 买单活动', + desc: '查看“你玩游戏我买单”活动,补偿领取和个人历史记录', + accentColor: '#16a34a', + fetchUrl: '/baccarat-loss-cover/summary', + openFn: () => { + closeGameHall(); + if (typeof openBaccaratLossCoverModal === 'function') { + openBaccaratLossCoverModal('overview'); + } + }, + renderStatus: (data) => { + const event = data?.event; + if (!event) { + return { + badge: '📭 暂无活动', + badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8', + detail: '当前没有进行中或待领取的买单活动' + }; + } + + const myStatus = event.my_record?.claim_status_label || '未参与'; + const total = Number(event.total_claimed_amount || 0).toLocaleString(); + + if (event.status === 'active') { + return { + badge: '🟢 进行中', + badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7', + detail: `我的状态:${myStatus} | 开启人:${event.creator_username}` + }; + } + + if (event.status === 'settlement_pending') { + return { + badge: '⏳ 结算中', + badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d', + detail: `活动已结束,等待最后几局结算 | 我的状态:${myStatus}` + }; + } + + if (event.status === 'claimable') { + return { + badge: '💰 可领取', + badgeStyle: 'background:#dcfce7; color:#166534; border:1px solid #86efac', + detail: `我的状态:${myStatus} | 已发补偿 ${total} 金币` + }; + } + + return { + badge: '🕒 即将开始', + badgeStyle: 'background:#ede9fe; color:#5b21b6; border:1px solid #c4b5fd', + detail: `开启人:${event.creator_username} | 我的状态:${myStatus}` + }; + }, + btnLabel: (data) => data?.event?.status === 'claimable' ? '💰 查看并领取' : '📜 查看活动', + }, { id: 'slot_machine', name: '🎰 老虎机', diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index c9e9182..ca4f78b 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -122,6 +122,9 @@ + {{-- 全屏特效按钮组(仅管理员可见) --}} + + + + + + + + +@endif diff --git a/routes/console.php b/routes/console.php index 18ae635..fc722b0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -45,6 +45,11 @@ Schedule::call(function () { ->each(fn ($e) => \App\Jobs\TriggerHolidayEventJob::dispatch($e)); })->everyMinute()->name('holiday-events:trigger')->withoutOverlapping(); +// 每分钟:推进百家乐买单活动状态(开始 / 等待结算 / 开放领取 / 过期收尾) +Schedule::call(function () { + app(\App\Services\BaccaratLossCoverService::class)->tick(); +})->everyMinute()->name('baccarat-loss-cover:tick')->withoutOverlapping(); + // ──────────── 百家乐定时任务 ───────────────────────────────────── // 每分钟:检查是否应开新一局(游戏开启 + 无正在进行的局) diff --git a/routes/web.php b/routes/web.php index ae0eb2f..dcaa3cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -142,6 +142,13 @@ Route::middleware(['chat.auth'])->group(function () { Route::get('/history', [\App\Http\Controllers\BaccaratController::class, 'history'])->name('history'); }); + // ── 百家乐买单活动(前台)─────────────────────────────────────── + Route::prefix('baccarat-loss-cover')->name('baccarat-loss-cover.')->group(function () { + Route::get('/summary', [\App\Http\Controllers\BaccaratLossCoverController::class, 'summary'])->name('summary'); + Route::get('/history', [\App\Http\Controllers\BaccaratLossCoverController::class, 'history'])->name('history'); + Route::post('/{event}/claim', [\App\Http\Controllers\BaccaratLossCoverController::class, 'claim'])->name('claim'); + }); + // ── 老虎机(前台)──────────────────────────────────────────────── Route::prefix('slot')->name('slot.')->group(function () { // 获取配置及今日剩余次数 @@ -295,6 +302,10 @@ Route::middleware(['chat.auth'])->group(function () { Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce'); Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen'); Route::post('/command/effect', [AdminCommandController::class, 'effect'])->name('command.effect'); + Route::middleware('chat.level:super')->group(function () { + Route::post('/command/baccarat-loss-cover', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'store'])->name('command.baccarat_loss_cover.store'); + Route::post('/command/baccarat-loss-cover/{event}/close', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'close'])->name('command.baccarat_loss_cover.close'); + }); // ---- 礼包红包(superlevel 发包 / 所有登录用户可抢)---- Route::post('/command/red-packet/send', [\App\Http\Controllers\RedPacketController::class, 'send'])->name('command.red_packet.send'); diff --git a/tests/Feature/BaccaratLossCoverControllerTest.php b/tests/Feature/BaccaratLossCoverControllerTest.php new file mode 100644 index 0000000..991e604 --- /dev/null +++ b/tests/Feature/BaccaratLossCoverControllerTest.php @@ -0,0 +1,203 @@ + 'baccarat'], + [ + 'name' => 'Baccarat', + 'icon' => 'baccarat', + 'description' => 'Baccarat Game', + 'enabled' => true, + 'params' => [ + 'min_bet' => 100, + 'max_bet' => 50000, + ], + ] + ); + } + + /** + * 验证 superlevel 管理员可以创建百家乐买单活动。 + */ + public function test_superlevel_admin_can_create_baccarat_loss_cover_event(): void + { + Queue::fake(); + + $admin = User::factory()->create(['user_level' => 100]); + + $response = $this->actingAs($admin)->postJson(route('command.baccarat_loss_cover.store'), [ + 'title' => '你玩游戏我买单', + 'description' => '测试活动', + 'starts_at' => now()->addMinutes(5)->toDateTimeString(), + 'ends_at' => now()->addMinutes(35)->toDateTimeString(), + 'claim_deadline_at' => now()->addDay()->toDateTimeString(), + ]); + + $response->assertOk()->assertJson(['ok' => true]); + + $this->assertDatabaseHas('baccarat_loss_cover_events', [ + 'title' => '你玩游戏我买单', + 'created_by_user_id' => $admin->id, + 'status' => 'scheduled', + ]); + } + + /** + * 验证活动进行中下注会挂到活动并写入用户聚合记录。 + */ + public function test_bet_during_active_event_is_tracked_in_loss_cover_record(): void + { + Event::fake(); + + $user = User::factory()->create(['jjb' => 500]); + $event = BaccaratLossCoverEvent::factory()->create([ + 'status' => 'active', + 'starts_at' => now()->subMinutes(2), + 'ends_at' => now()->addMinutes(20), + ]); + + $round = BaccaratRound::forceCreate([ + 'status' => 'betting', + 'bet_opens_at' => now(), + 'bet_closes_at' => now()->addMinutes(1), + 'total_bet_big' => 0, + 'total_bet_small' => 0, + 'total_bet_triple' => 0, + 'bet_count' => 0, + 'bet_count_big' => 0, + 'bet_count_small' => 0, + 'bet_count_triple' => 0, + 'total_payout' => 0, + ]); + + $response = $this->actingAs($user)->postJson(route('baccarat.bet'), [ + 'round_id' => $round->id, + 'bet_type' => 'big', + 'amount' => 100, + ]); + + $response->assertOk()->assertJson(['ok' => true]); + + $this->assertDatabaseHas('baccarat_bets', [ + 'round_id' => $round->id, + 'user_id' => $user->id, + 'loss_cover_event_id' => $event->id, + 'amount' => 100, + ]); + + $this->assertDatabaseHas('baccarat_loss_cover_records', [ + 'event_id' => $event->id, + 'user_id' => $user->id, + 'total_bet_amount' => 100, + 'claim_status' => 'not_eligible', + ]); + + $this->assertDatabaseHas('baccarat_loss_cover_events', [ + 'id' => $event->id, + 'participant_count' => 1, + ]); + } + + /** + * 验证用户领取补偿后会增加金币并写入金币流水。 + */ + public function test_user_can_claim_baccarat_loss_cover_and_currency_log_is_written(): void + { + $user = User::factory()->create(['jjb' => 200]); + $event = BaccaratLossCoverEvent::factory()->create([ + 'status' => 'claimable', + 'claim_deadline_at' => now()->addHours(12), + 'total_loss_amount' => 300, + ]); + + BaccaratLossCoverRecord::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $user->id, + 'total_bet_amount' => 600, + 'total_loss_amount' => 300, + 'compensation_amount' => 300, + 'claim_status' => 'pending', + ]); + + $response = $this->actingAs($user)->postJson(route('baccarat-loss-cover.claim', $event)); + + $response->assertOk()->assertJson([ + 'ok' => true, + 'amount' => 300, + ]); + + $this->assertSame(500, (int) $user->fresh()->jjb); + + $this->assertDatabaseHas('baccarat_loss_cover_records', [ + 'event_id' => $event->id, + 'user_id' => $user->id, + 'claim_status' => 'claimed', + 'claimed_amount' => 300, + ]); + + $this->assertDatabaseHas('user_currency_logs', [ + 'user_id' => $user->id, + 'currency' => 'gold', + 'amount' => 300, + 'source' => CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value, + ]); + } + + /** + * 验证历史接口会返回当前用户在活动中的领取状态。 + */ + public function test_history_endpoint_contains_my_claim_status(): void + { + $user = User::factory()->create(); + $event = BaccaratLossCoverEvent::factory()->create([ + 'status' => 'completed', + 'total_claimed_amount' => 400, + ]); + + BaccaratLossCoverRecord::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $user->id, + 'claim_status' => 'claimed', + 'claimed_amount' => 400, + 'compensation_amount' => 400, + 'claimed_at' => now(), + ]); + + $response = $this->actingAs($user)->getJson(route('baccarat-loss-cover.history')); + + $response->assertOk(); + $response->assertJsonPath('events.0.id', $event->id); + $response->assertJsonPath('events.0.my_record.claim_status', 'claimed'); + $response->assertJsonPath('events.0.my_record.claimed_amount', 400); + } +}