diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index ac71a48..e35a859 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -26,6 +26,7 @@ use App\Models\User; use App\Services\AppointmentService; use App\Services\ChatStateService; use App\Services\ChatUserPresenceService; +use App\Services\DailyGameProfitLeaderboardService; use App\Services\MessageFilterService; use App\Services\PositionPermissionService; use App\Services\RideService; @@ -63,6 +64,7 @@ class ChatController extends Controller private readonly VipService $vipService, private readonly \App\Services\ShopService $shopService, private readonly UserCurrencyService $currencyService, + private readonly DailyGameProfitLeaderboardService $dailyGameProfitLeaderboardService, private readonly AppointmentService $appointmentService, private readonly RoomBroadcastService $broadcast, private readonly PositionPermissionService $positionPermissionService, @@ -372,6 +374,7 @@ class ChatController extends Controller 'pendingDivorce' => $pendingDivorceData, 'roomPermissionMap' => $roomPermissionMap, 'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true), + 'dailyGameProfitLeaders' => $this->dailyGameProfitLeaderboardService->topThree(), 'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(), 'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user), ]); diff --git a/app/Services/DailyGameProfitLeaderboardService.php b/app/Services/DailyGameProfitLeaderboardService.php new file mode 100644 index 0000000..ad8e146 --- /dev/null +++ b/app/Services/DailyGameProfitLeaderboardService.php @@ -0,0 +1,103 @@ + '金库爆破王', + 2 => '马桌双修财神', + 3 => '金币收割机', + ]; + + /** + * 参与净盈利统计的游戏流水来源。 + */ + private const GAME_PROFIT_SOURCES = [ + CurrencySource::BACCARAT_BET, + CurrencySource::BACCARAT_WIN, + CurrencySource::HORSE_BET, + CurrencySource::HORSE_WIN, + ]; + + /** + * 获取指定日期的游戏净盈利前三榜。 + * + * @return Collection + */ + public function topThree(?string $date = null): Collection + { + $statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay(); + $cacheKey = 'daily_game_profit_leaderboard:v2:'.$statsDate->toDateString(); + + return Cache::remember($cacheKey, 300, function () use ($statsDate) { + $rangeStart = $statsDate; + $rangeEnd = $statsDate->addDay(); + + return UserCurrencyLog::query() + ->join('users', 'users.id', '=', 'user_currency_logs.user_id') + ->where('user_currency_logs.currency', 'gold') + ->whereIn('user_currency_logs.source', array_map( + fn (CurrencySource $source): string => $source->value, + self::GAME_PROFIT_SOURCES + )) + ->where('user_currency_logs.created_at', '>=', $rangeStart) + ->where('user_currency_logs.created_at', '<', $rangeEnd) + ->where('users.username', '!=', 'AI小班长') + ->groupBy('user_currency_logs.user_id', 'users.username', 'users.usersf') + ->havingRaw('SUM(user_currency_logs.amount) > 0') + ->orderByRaw('SUM(user_currency_logs.amount) DESC') + ->orderBy('user_currency_logs.user_id') + ->limit(3) + ->selectRaw('user_currency_logs.user_id, users.username, users.usersf, SUM(user_currency_logs.amount) as net_profit') + ->get() + ->values() + ->map(function (object $row, int $index): object { + $rank = $index + 1; + + return (object) [ + 'rank' => $rank, + 'title' => self::TITLES[$rank], + 'user_id' => (int) $row->user_id, + 'username' => (string) $row->username, + 'headface_url' => $this->resolveHeadfaceUrl((string) ($row->usersf ?: '1.gif')), + 'net_profit' => (int) $row->net_profit, + ]; + }); + }); + } + + /** + * 解析榜单头像地址。 + */ + private function resolveHeadfaceUrl(string $headface): string + { + if (str_starts_with($headface, 'storage/')) { + return '/'.$headface; + } + + return '/images/headface/'.strtolower($headface); + } +} diff --git a/resources/css/chat.css b/resources/css/chat.css index f6867eb..38d3198 100644 --- a/resources/css/chat.css +++ b/resources/css/chat.css @@ -170,6 +170,7 @@ a:hover { .room-title-bar { background: var(--bg-header); color: var(--text-white); + position: relative; text-align: center; padding: 6px 10px; font-size: 12px; @@ -178,6 +179,10 @@ a:hover { border-bottom: 1px solid var(--border-blue); } +.room-title-main { + text-align: center; +} + .room-title-bar .room-name { font-weight: bold; font-size: 14px; @@ -188,6 +193,145 @@ a:hover { font-size: 11px; } +.daily-game-profit-board { + position: absolute; + top: 8px; + right: 34px; + width: 132px; + color: #fff7ed; + text-align: center; + font-size: 13px; + line-height: 1.28; + z-index: 2; +} + +.daily-game-profit-board-title { + color: #fde68a; + font-weight: bold; + margin-bottom: 5px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); +} + +.daily-game-profit-avatars { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.daily-game-profit-avatar-wrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 999px; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.24); +} + +.daily-game-profit-avatar { + width: 30px; + height: 30px; + border-radius: 999px; + object-fit: cover; + background: rgba(255, 255, 255, 0.18); +} + +.daily-game-profit-avatar-rank-1 { + background: linear-gradient(135deg, #fde047, #f59e0b 48%, #fff7ad); +} + +.daily-game-profit-avatar-rank-1::before { + content: "1"; + position: absolute; + right: -2px; + bottom: -2px; + width: 14px; + height: 14px; + border-radius: 999px; + background: #f59e0b; + color: #fff7ed; + font-size: 9px; + font-weight: bold; + line-height: 14px; + text-align: center; + box-shadow: 0 0 0 1px rgba(255, 247, 237, 0.9); +} + +.daily-game-profit-avatar-rank-2 { + background: linear-gradient(135deg, #f8fafc, #94a3b8 48%, #ffffff); +} + +.daily-game-profit-avatar-rank-2::before { + content: "2"; + position: absolute; + right: -2px; + bottom: -2px; + width: 14px; + height: 14px; + border-radius: 999px; + background: #64748b; + color: #ffffff; + font-size: 9px; + font-weight: bold; + line-height: 14px; + text-align: center; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9); +} + +.daily-game-profit-avatar-rank-3 { + background: linear-gradient(135deg, #fed7aa, #c2410c 48%, #ffedd5); +} + +.daily-game-profit-avatar-rank-3::before { + content: "3"; + position: absolute; + right: -2px; + bottom: -2px; + width: 14px; + height: 14px; + border-radius: 999px; + background: #c2410c; + color: #fff7ed; + font-size: 9px; + font-weight: bold; + line-height: 14px; + text-align: center; + box-shadow: 0 0 0 1px rgba(255, 247, 237, 0.9); +} + +.daily-game-profit-avatar-wrap::after { + content: attr(data-username); + position: absolute; + left: 50%; + bottom: -24px; + transform: translateX(-50%); + max-width: 110px; + padding: 3px 7px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.88); + color: #ffffff; + font-size: 11px; + line-height: 1.2; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + overflow: hidden; + text-overflow: ellipsis; +} + +.daily-game-profit-avatar-wrap:hover::after { + opacity: 1; +} + +.daily-game-profit-empty { + color: #dbeafe; + font-size: 11px; + opacity: 0.82; +} + /* 公告/祝福语滚动条(复刻原版 marquee) */ .room-announcement-bar { background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8); @@ -956,6 +1100,48 @@ a:hover { overflow-y: auto !important; } + .daily-game-profit-board { + top: 5px; + right: 6px; + width: 102px; + font-size: 10px; + } + + .daily-game-profit-empty { + display: none; + } + + .daily-game-profit-board-title { + margin-bottom: 2px; + } + + .daily-game-profit-avatars { + gap: 4px; + } + + .daily-game-profit-avatar-wrap { + width: 25px; + height: 25px; + } + + .daily-game-profit-avatar { + width: 22px; + height: 22px; + } + + .daily-game-profit-avatar-rank-1::before, + .daily-game-profit-avatar-rank-2::before, + .daily-game-profit-avatar-rank-3::before { + width: 11px; + height: 11px; + font-size: 8px; + line-height: 11px; + } + + .daily-game-profit-avatar-wrap::after { + display: none; + } + /* ── 手机端隐藏房间介绍 ── */ .room-desc { display: none !important; diff --git a/resources/views/chat/partials/layout/header.blade.php b/resources/views/chat/partials/layout/header.blade.php index f3a50a3..bb831e3 100644 --- a/resources/views/chat/partials/layout/header.blade.php +++ b/resources/views/chat/partials/layout/header.blade.php @@ -7,9 +7,28 @@ {{-- 顶部标题栏(第一行:房间名称 + 介绍) --}}
-
●{{ $room->name }}●在线0
-
{{ $room->description ?? '欢迎光临本聊天室!畅所欲言,文明聊天。' }} +
+
●{{ $room->name }}●在线0
+
{{ $room->description ?? '欢迎光临本聊天室!畅所欲言,文明聊天。' }} +
+
+ +
+
今日游戏三杰
+ @if (($dailyGameProfitLeaders ?? collect())->isNotEmpty()) +
+ @foreach ($dailyGameProfitLeaders as $leader) + + {{ $leader->username }} + + @endforeach +
+ @else +
今日财神位待开张
+ @endif
diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 8784ff6..652dfd2 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -8,6 +8,7 @@ namespace Tests\Feature; +use App\Enums\CurrencySource; use App\Events\MessageSent; use App\Models\Department; use App\Models\Gift; @@ -16,6 +17,7 @@ use App\Models\Ride; use App\Models\Room; use App\Models\Sysparam; use App\Models\User; +use App\Models\UserCurrencyLog; use App\Models\UserPosition; use App\Models\UserRidePurchase; use App\Models\VipLevel; @@ -67,6 +69,57 @@ class ChatControllerTest extends TestCase $this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username)); } + /** + * 测试聊天室顶部会展示百家乐与赛马今日净盈利前三。 + */ + public function test_room_header_displays_daily_game_profit_top_three(): void + { + Cache::flush(); + Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']); + + $room = Room::create(['room_name' => 'profitroom']); + $viewer = User::factory()->create(['username' => '围观群众', 'user_level' => 1]); + $first = User::factory()->create(['username' => '第一赢家', 'user_level' => 1]); + $second = User::factory()->create(['username' => '第二赢家', 'user_level' => 1]); + $third = User::factory()->create(['username' => '第三赢家', 'user_level' => 1]); + $yesterdayWinner = User::factory()->create(['username' => '昨日财神', 'user_level' => 1]); + $breakEvenUser = User::factory()->create(['username' => '白忙选手', 'user_level' => 1]); + $admin = User::factory()->create(['username' => '超级管理员', 'user_level' => 100]); + $bot = User::factory()->create(['username' => 'AI小班长', 'user_level' => 1]); + + $this->recordGameGoldLog($first, CurrencySource::BACCARAT_WIN, 10000); + $this->recordGameGoldLog($first, CurrencySource::BACCARAT_BET, -2500); + $this->recordGameGoldLog($second, CurrencySource::HORSE_WIN, 6000); + $this->recordGameGoldLog($second, CurrencySource::HORSE_BET, -1000); + $this->recordGameGoldLog($third, CurrencySource::BACCARAT_WIN, 3000); + $this->recordGameGoldLog($yesterdayWinner, CurrencySource::HORSE_WIN, 50000, now()->subDay()); + $this->recordGameGoldLog($breakEvenUser, CurrencySource::BACCARAT_WIN, 1000); + $this->recordGameGoldLog($breakEvenUser, CurrencySource::BACCARAT_BET, -1000); + $this->recordGameGoldLog($admin, CurrencySource::BACCARAT_WIN, 999999); + $this->recordGameGoldLog($bot, CurrencySource::HORSE_WIN, 888888); + + $response = $this->actingAs($viewer)->get(route('chat.room', $room->id)); + + $leaders = $response->viewData('dailyGameProfitLeaders'); + + $this->assertSame(['超级管理员', '第一赢家', '第二赢家'], $leaders->pluck('username')->all()); + $this->assertSame([999999, 7500, 5000], $leaders->pluck('net_profit')->all()); + $this->assertStringStartsWith('/images/headface/', $leaders->first()->headface_url); + + $response->assertOk(); + $response->assertSee('今日游戏三杰'); + $response->assertSee('class="daily-game-profit-avatar"', false); + $response->assertSee('data-username="超级管理员"', false); + $response->assertSee('data-username="第一赢家"', false); + $response->assertSee('data-username="第二赢家"', false); + $response->assertDontSee('金库爆破王'); + $response->assertDontSee('马桌双修财神'); + $response->assertDontSee('金币收割机'); + $response->assertDontSee('+999,999'); + $response->assertDontSee('+7,500'); + $response->assertDontSee('+5,000'); + } + /** * 测试关闭房间会拒绝普通用户直接进入。 */ @@ -1499,6 +1552,23 @@ class ChatControllerTest extends TestCase $response->assertStatus(403); } + /** + * 记录一条游戏金币流水,用于构造每日游戏净盈利榜测试数据。 + */ + private function recordGameGoldLog(User $user, CurrencySource $source, int $amount, \DateTimeInterface|string|null $createdAt = null): void + { + UserCurrencyLog::forceCreate([ + 'user_id' => $user->id, + 'username' => $user->username, + 'currency' => 'gold', + 'amount' => $amount, + 'balance_after' => max(0, 100000 + $amount), + 'source' => $source->value, + 'remark' => '每日游戏净盈利榜测试', + 'created_at' => $createdAt ?? now(), + ]); + } + /** * 创建带指定聊天室权限的在职职务用户。 *