diff --git a/app/Jobs/CloseHorseRaceJob.php b/app/Jobs/CloseHorseRaceJob.php index e4365ee..09c70f9 100644 --- a/app/Jobs/CloseHorseRaceJob.php +++ b/app/Jobs/CloseHorseRaceJob.php @@ -93,12 +93,14 @@ class CloseHorseRaceJob implements ShouldQueue ->get(); $totalPayout = 0; + $participantSettlements = []; - DB::transaction(function () use ($bets, $winnerId, $distributablePool, $winnerPool, $currency, &$totalPayout) { + DB::transaction(function () use ($bets, $winnerId, $distributablePool, $winnerPool, $currency, &$totalPayout, &$participantSettlements) { foreach ($bets as $bet) { if ((int) $bet->horse_id !== $winnerId) { // 未中奖(本金已在下注时扣除) $bet->update(['status' => 'lost', 'payout' => 0]); + $this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0); continue; } @@ -122,6 +124,8 @@ class CloseHorseRaceJob implements ShouldQueue ); } + // 结算提示需要显示本场净输赢,因此要减去下注时已支付的本金。 + $this->recordParticipantSettlement($participantSettlements, $bet, $payout - $bet->amount, $payout); $totalPayout += $payout; } }); @@ -129,24 +133,111 @@ class CloseHorseRaceJob implements ShouldQueue // 公屏公告 $this->pushResultMessage($race, $chatState, $totalPayout); + // 参与者私聊结算提醒 + $this->pushParticipantToastNotifications($race, $chatState, $participantSettlements); + // 广播结算事件 broadcast(new HorseRaceSettled($race)); } + /** + * 汇总单个参与者本场的下注、返还与净输赢金额。 + * + * @param array> $participantSettlements + */ + private function recordParticipantSettlement(array &$participantSettlements, HorseBet $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, + 'horse_id' => (int) $bet->horse_id, + ]; + + // 即使出现脏数据导致同一用户多笔下注,也统一汇总成本场总输赢。 + $existing['bet_amount'] += (int) $bet->amount; + $existing['payout'] += $payout; + $existing['net_change'] += $netChange; + $existing['horse_id'] = (int) $bet->horse_id; + + $participantSettlements[$userId] = $existing; + } + + /** + * 向参与本场的用户发送私聊结算提示,并复用右下角 toast 通知。 + * + * @param array> $participantSettlements + */ + private function pushParticipantToastNotifications(HorseRace $race, ChatStateService $chatState, array $participantSettlements): void + { + if ($participantSettlements === []) { + return; + } + + $roomId = 1; + $winnerName = $this->resolveWinnerHorseName($race); + + 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); + $horseId = (int) $settlement['horse_id']; + + $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 + ? "冠军:{$winnerName}
你本场净赢 +{$absNetChange} 金币!" + : ($netChange < 0 + ? "冠军:{$winnerName}
你本场净输 -{$absNetChange} 金币。" + : "冠军:{$winnerName}
你本场不输不赢。"); + + // 写入系统私聊,方便用户在聊天历史中回看本场结算结果。 + $msg = [ + 'id' => $chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统', + 'to_user' => $username, + 'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。", + 'is_secret' => true, + 'font_color' => '#f59e0b', + '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); + } + } + /** * 向公屏发送赛果系统消息。 */ private function pushResultMessage(HorseRace $race, ChatStateService $chatState, int $totalPayout): void { // 找出胜利马匹名称 - $horses = $race->horses ?? []; - $winnerName = '未知'; - foreach ($horses as $horse) { - if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) { - $winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? ''); - break; - } - } + $winnerName = $this->resolveWinnerHorseName($race); $payoutText = $totalPayout > 0 ? '共派发 💰'.number_format($totalPayout).' 金币' @@ -169,4 +260,19 @@ class CloseHorseRaceJob implements ShouldQueue broadcast(new MessageSent(1, $msg)); SaveMessageJob::dispatch($msg); } + + /** + * 解析冠军马匹的展示名称。 + */ + private function resolveWinnerHorseName(HorseRace $race): string + { + $horses = $race->horses ?? []; + foreach ($horses as $horse) { + if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) { + return ($horse['emoji'] ?? '').($horse['name'] ?? ''); + } + } + + return '未知'; + } } diff --git a/tests/Feature/HorseRaceControllerTest.php b/tests/Feature/HorseRaceControllerTest.php index 33c74ec..2aacb0c 100644 --- a/tests/Feature/HorseRaceControllerTest.php +++ b/tests/Feature/HorseRaceControllerTest.php @@ -11,6 +11,7 @@ namespace Tests\Feature; use App\Events\HorseRaceSettled; use App\Jobs\CloseHorseRaceJob; +use App\Jobs\SaveMessageJob; use App\Models\GameConfig; use App\Models\HorseBet; use App\Models\HorseRace; @@ -19,6 +20,7 @@ use App\Services\ChatStateService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Redis; use Tests\TestCase; /** @@ -414,4 +416,61 @@ class HorseRaceControllerTest extends TestCase $this->assertSame(5000, $payload['winner_pool']); $this->assertSame(9500, $payload['distributable_pool']); } + + /** + * 方法功能:验证赛马开奖后会给参与者发送带右下角提示的私聊结算通知。 + */ + public function test_settlement_pushes_private_toast_notification_to_participant(): void + { + Queue::fake(); + + /** @var \App\Models\User $user */ + $user = User::factory()->create(['jjb' => 500]); + + $race = HorseRace::create([ + 'status' => 'running', + 'bet_opens_at' => now()->subMinutes(2), + 'bet_closes_at' => now()->subMinute(), + 'race_starts_at' => now()->subSeconds(30), + 'winner_horse_id' => 1, + 'horses' => [ + ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'], + ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'], + ], + 'total_pool' => 100, + ]); + + HorseBet::create([ + 'race_id' => $race->id, + 'user_id' => $user->id, + 'horse_id' => 2, + 'amount' => 100, + 'status' => 'pending', + ]); + + // 模拟下注时已扣款,便于校验结算通知中的净输赢语义。 + $user->decrement('jjb', 100); + + (new CloseHorseRaceJob($race))->handle( + app(\App\Services\UserCurrencyService::class), + app(ChatStateService::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); + } }