diff --git a/app/Jobs/CloseBaccaratRoundJob.php b/app/Jobs/CloseBaccaratRoundJob.php index 0a7009a..077cfa4 100644 --- a/app/Jobs/CloseBaccaratRoundJob.php +++ b/app/Jobs/CloseBaccaratRoundJob.php @@ -96,8 +96,9 @@ class CloseBaccaratRoundJob implements ShouldQueue // 收集各用户输赢结果,用于公屏展示 $winners = []; $losers = []; + $participantSettlements = []; - DB::transaction(function () use ($bets, $result, $config, $currency, $lossCoverService, &$totalPayout, &$winners, &$losers) { + DB::transaction(function () use ($bets, $result, $config, $currency, $lossCoverService, &$totalPayout, &$winners, &$losers, &$participantSettlements) { foreach ($bets as $bet) { /** @var \App\Models\BaccaratBet $bet */ $username = $bet->user->username ?? '匿名'; @@ -107,6 +108,7 @@ class CloseBaccaratRoundJob implements ShouldQueue $bet->update(['status' => 'lost', 'payout' => 0]); $lossCoverService->registerSettlement($bet->fresh()); $losers[] = "{$username}-{$bet->amount}"; + $this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0); if ($username === 'AI小班长') { $this->handleAiLoseStreak(); @@ -131,6 +133,8 @@ class CloseBaccaratRoundJob implements ShouldQueue $totalPayout += $payout; $lossCoverService->registerSettlement($bet->fresh()); $winners[] = "{$username}+".number_format($payout); + // 结算提醒展示的是本局净输赢,因此要扣除下注时已经支付的本金。 + $this->recordParticipantSettlement($participantSettlements, $bet, $payout - $bet->amount, $payout); if ($username === 'AI小班长') { Redis::del('ai_baccarat_lose_streak'); // 赢了清空连输 @@ -139,6 +143,7 @@ class CloseBaccaratRoundJob implements ShouldQueue $bet->update(['status' => 'lost', 'payout' => 0]); $lossCoverService->registerSettlement($bet->fresh()); $losers[] = "{$username}-".number_format($bet->amount); + $this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0); if ($username === 'AI小班长') { $this->handleAiLoseStreak(); @@ -166,6 +171,9 @@ class CloseBaccaratRoundJob implements ShouldQueue // ── 公屏公告 ───────────────────────────────────────────────── $this->pushResultMessage($round, $chatState, $winners, $losers); + + // ── 参与者私聊提醒 ──────────────────────────────────────────── + $this->pushParticipantToastNotifications($round, $chatState, $participantSettlements); } /** @@ -180,6 +188,94 @@ class CloseBaccaratRoundJob implements ShouldQueue } } + /** + * 汇总单个参与者本局的下注、返还与净输赢金额。 + * + * @param array> $participantSettlements + */ + private function recordParticipantSettlement(array &$participantSettlements, BaccaratBet $bet, int $netChange, int $payout): void + { + if (! $bet->user) { + return; + } + + $userId = (int) $bet->user->id; + $existing = $participantSettlements[$userId] ?? [ + 'user' => $bet->user, + 'username' => $bet->user->username, + 'bet_amount' => 0, + 'payout' => 0, + 'net_change' => 0, + ]; + + // 同一用户若存在多条下注记录,这里统一聚合成本局总输赢。 + $existing['bet_amount'] += (int) $bet->amount; + $existing['payout'] += $payout; + $existing['net_change'] += $netChange; + + $participantSettlements[$userId] = $existing; + } + + /** + * 向参与本局的用户发送私聊结算提示,并复用右下角 toast 通知。 + * + * @param array> $participantSettlements + */ + private function pushParticipantToastNotifications(BaccaratRound $round, ChatStateService $chatState, array $participantSettlements): void + { + if ($participantSettlements === []) { + return; + } + + $roomId = 1; + $roundResultLabel = $round->resultLabel(); + + foreach ($participantSettlements as $settlement) { + $user = $settlement['user']; + $username = (string) $settlement['username']; + $betAmount = (int) $settlement['bet_amount']; + $netChange = (int) $settlement['net_change']; + $freshGold = (int) ($user->fresh()->jjb ?? 0); + + $absNetChange = number_format(abs($netChange)); + $betAmountText = number_format($betAmount); + $summaryText = $netChange > 0 + ? "净赢 {$absNetChange} 金币" + : ($netChange < 0 ? "净输 {$absNetChange} 金币" : '不输不赢'); + $toastIcon = $netChange > 0 ? '🎉' : ($netChange < 0 ? '📉' : '🎲'); + $toastColor = $netChange > 0 ? '#10b981' : ($netChange < 0 ? '#ef4444' : '#3b82f6'); + $toastMessage = $netChange > 0 + ? "本局结果:{$roundResultLabel}
你本局净赢 +{$absNetChange} 金币!" + : ($netChange < 0 + ? "本局结果:{$roundResultLabel}
你本局净输 -{$absNetChange} 金币。" + : "本局结果:{$roundResultLabel}
你本局不输不赢。"); + + // 写入系统私聊,确保用户刷新聊天室后仍能看到本局输赢记录。 + $msg = [ + 'id' => $chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统', + 'to_user' => $username, + 'content' => "🎲 百家乐第 #{$round->id} 局已开奖,结果:{$roundResultLabel}。你本局下注 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。", + 'is_secret' => true, + 'font_color' => '#8b5cf6', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + 'toast_notification' => [ + 'title' => '🎲 百家乐本局结算', + 'message' => $toastMessage, + 'icon' => $toastIcon, + 'color' => $toastColor, + 'duration' => 10000, + ], + ]; + + $chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + } + } + /** * 向公屏发送开奖结果系统消息(含各用户输赢情况)。 * diff --git a/tests/Feature/BaccaratControllerTest.php b/tests/Feature/BaccaratControllerTest.php index b1a7c57..22a2f1b 100644 --- a/tests/Feature/BaccaratControllerTest.php +++ b/tests/Feature/BaccaratControllerTest.php @@ -1,15 +1,28 @@ assertJsonStructure(['history']); $this->assertCount(1, $response->json('history')); } + + /** + * 测试百家乐开奖后会给参与用户发送带右下角提示的私聊结算通知。 + */ + public function test_settlement_pushes_private_toast_notification_to_participant(): void + { + Queue::fake(); + + GameConfig::updateOrCreate( + ['game_key' => 'baccarat'], + [ + 'name' => 'Baccarat', + 'icon' => 'baccarat', + 'description' => 'Baccarat Game', + 'enabled' => true, + // 将 3~18 全部设为庄家收割,确保本测试稳定命中亏损分支。 + 'params' => [ + 'min_bet' => 100, + 'max_bet' => 50000, + 'kill_points' => range(3, 18), + ], + ] + ); + + /** @var \App\Models\User $user */ + $user = User::factory()->create(['jjb' => 400]); + + $round = BaccaratRound::forceCreate([ + 'status' => 'betting', + 'bet_opens_at' => now()->subMinute(), + 'bet_closes_at' => now()->subSecond(), + 'total_bet_big' => 100, + 'total_bet_small' => 0, + 'total_bet_triple' => 0, + 'bet_count' => 1, + 'bet_count_big' => 1, + 'bet_count_small' => 0, + 'bet_count_triple' => 0, + 'total_payout' => 0, + ]); + + BaccaratBet::forceCreate([ + 'round_id' => $round->id, + 'user_id' => $user->id, + 'bet_type' => 'big', + 'amount' => 100, + 'payout' => 0, + 'status' => 'pending', + ]); + + (new CloseBaccaratRoundJob($round))->handle( + app(\App\Services\UserCurrencyService::class), + app(\App\Services\ChatStateService::class), + app(\App\Services\BaccaratLossCoverService::class), + ); + + $messages = Redis::lrange('room:1:messages', 0, -1); + $privateMessage = collect($messages) + ->map(fn (string $item) => json_decode($item, true)) + ->first(fn (array $item) => ($item['from_user'] ?? null) === '系统' + && ($item['to_user'] ?? null) === $user->username + && ($item['toast_notification']['title'] ?? null) === '🎲 百家乐本局结算'); + + $this->assertNotNull($privateMessage); + $this->assertTrue((bool) ($privateMessage['is_secret'] ?? false)); + $this->assertStringContainsString('净输 100 金币', (string) ($privateMessage['content'] ?? '')); + $this->assertStringContainsString('-100', (string) ($privateMessage['toast_notification']['message'] ?? '')); + $this->assertSame('📉', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#ef4444', $privateMessage['toast_notification']['color'] ?? null); + + // 公屏开奖消息 + 参与者私聊通知,都应进入异步落库队列。 + Queue::assertPushed(SaveMessageJob::class, 2); + } } diff --git a/tests/Feature/Feature/AdminCommandControllerTest.php b/tests/Feature/Feature/AdminCommandControllerTest.php index 614930c..048c879 100644 --- a/tests/Feature/Feature/AdminCommandControllerTest.php +++ b/tests/Feature/Feature/AdminCommandControllerTest.php @@ -7,9 +7,12 @@ namespace Tests\Feature\Feature; +use App\Jobs\SaveMessageJob; use App\Models\Room; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Redis; use Tests\TestCase; /** @@ -45,4 +48,54 @@ class AdminCommandControllerTest extends TestCase ]); } } + + /** + * 测试管理操作中的奖励金币会给接收方写入带右下角提示的私聊消息。 + */ + public function test_reward_gold_message_contains_toast_notification_for_receiver(): void + { + Queue::fake(); + + // 站长账号要求 id=1,才能走无职务限制的超管奖励路径。 + $admin = User::factory()->create([ + 'id' => 1, + 'user_level' => 100, + ]); + $target = User::factory()->create([ + 'jjb' => 50, + ]); + $room = Room::create([ + 'room_name' => '奖励金币房', + ]); + + $response = $this->actingAs($admin)->postJson(route('command.reward'), [ + 'username' => $target->username, + 'room_id' => $room->id, + 'amount' => 66, + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + 'message' => "已向 {$target->username} 发放 66 金币奖励 🎉", + ]); + + // 奖励金额必须真实到账,不能只有提示没有入账。 + $this->assertSame(116, (int) $target->fresh()->jjb); + + $messages = Redis::lrange("room:{$room->id}:messages", 0, -1); + $privateMessage = collect($messages) + ->map(fn (string $item) => json_decode($item, true)) + ->first(fn (array $item) => ($item['from_user'] ?? null) === '系统' + && ($item['to_user'] ?? null) === $target->username + && str_contains((string) ($item['content'] ?? ''), '向你发放了 66 枚金币奖励')); + + $this->assertNotNull($privateMessage); + $this->assertTrue((bool) ($privateMessage['is_secret'] ?? false)); + $this->assertSame('💰 奖励金币到账', $privateMessage['toast_notification']['title'] ?? null); + $this->assertSame('💰', $privateMessage['toast_notification']['icon'] ?? null); + $this->assertSame('#f59e0b', $privateMessage['toast_notification']['color'] ?? null); + + // 奖励公告和接收者私信都应进入异步落库队列。 + Queue::assertPushed(SaveMessageJob::class, 2); + } }