Browse Source

世界杯活动

laowu 18 giờ trước cách đây
mục cha
commit
1a9ad66436
54 tập tin đã thay đổi với 9236 bổ sung0 xóa
  1. 28 0
      app/Console/Commands/WorldCupGenerateMissingReferralRewards.php
  2. 2 0
      app/Console/Kernel.php
  3. 218 0
      app/Http/Controllers/Admin/WorldCupMarketController.php
  4. 175 0
      app/Http/Controllers/Admin/WorldCupReviewController.php
  5. 163 0
      app/Http/Controllers/Admin/WorldCupScheduleController.php
  6. 231 0
      app/Http/Controllers/Game/WorldCupActivityController.php
  7. 35 0
      app/Listeners/GenerateWorldCupReferralRewardOnOrderPaid.php
  8. 2 0
      app/Providers/EventServiceProvider.php
  9. 309 0
      app/Services/WorldCup/Repositories/SqlWorldCupBetRepository.php
  10. 104 0
      app/Services/WorldCup/Repositories/SqlWorldCupMatchRepository.php
  11. 125 0
      app/Services/WorldCup/Repositories/SqlWorldCupOddsRepository.php
  12. 386 0
      app/Services/WorldCup/Repositories/SqlWorldCupReferralRepository.php
  13. 310 0
      app/Services/WorldCup/Repositories/SqlWorldCupReviewRepository.php
  14. 105 0
      app/Services/WorldCup/Repositories/SqlWorldCupScheduleRepository.php
  15. 214 0
      app/Services/WorldCup/Repositories/SqlWorldCupSettlementRepository.php
  16. 24 0
      app/Services/WorldCup/Repositories/WorldCupBetRepositoryInterface.php
  17. 22 0
      app/Services/WorldCup/Repositories/WorldCupMatchRepositoryInterface.php
  18. 16 0
      app/Services/WorldCup/Repositories/WorldCupOddsRepositoryInterface.php
  19. 32 0
      app/Services/WorldCup/Repositories/WorldCupReferralRepositoryInterface.php
  20. 39 0
      app/Services/WorldCup/Repositories/WorldCupReviewRepositoryInterface.php
  21. 24 0
      app/Services/WorldCup/Repositories/WorldCupScheduleRepositoryInterface.php
  22. 20 0
      app/Services/WorldCup/Repositories/WorldCupSettlementRepositoryInterface.php
  23. 359 0
      app/Services/WorldCup/WorldCupBetService.php
  24. 84 0
      app/Services/WorldCup/WorldCupMatchFavoriteService.php
  25. 251 0
      app/Services/WorldCup/WorldCupOddsService.php
  26. 244 0
      app/Services/WorldCup/WorldCupReferralRewardService.php
  27. 35 0
      app/Services/WorldCup/WorldCupReferralService.php
  28. 178 0
      app/Services/WorldCup/WorldCupReviewService.php
  29. 308 0
      app/Services/WorldCup/WorldCupScheduleUpdateService.php
  30. 105 0
      app/Services/WorldCup/WorldCupSettlementService.php
  31. 820 0
      database/world_cup_activity.sql
  32. 104 0
      resources/views/admin/world_cup/bets.blade.php
  33. 321 0
      resources/views/admin/world_cup/kpi.blade.php
  34. 137 0
      resources/views/admin/world_cup/logs.blade.php
  35. 449 0
      resources/views/admin/world_cup/odds.blade.php
  36. 308 0
      resources/views/admin/world_cup/rewards.blade.php
  37. 228 0
      resources/views/admin/world_cup/schedule.blade.php
  38. 175 0
      resources/views/admin/world_cup/settlement.blade.php
  39. 12 0
      routes/game.php
  40. 24 0
      routes/web.php
  41. 71 0
      tests/Unit/GenerateWorldCupReferralRewardOnOrderPaidTest.php
  42. 470 0
      tests/Unit/WorldCupBetServiceTest.php
  43. 196 0
      tests/Unit/WorldCupMatchFavoriteServiceTest.php
  44. 22 0
      tests/Unit/WorldCupOddsAdminViewTest.php
  45. 268 0
      tests/Unit/WorldCupOddsServiceTest.php
  46. 44 0
      tests/Unit/WorldCupOddsSqlSeedTest.php
  47. 448 0
      tests/Unit/WorldCupReferralRewardServiceTest.php
  48. 45 0
      tests/Unit/WorldCupReferralServiceTest.php
  49. 116 0
      tests/Unit/WorldCupRepositoryNoLockTest.php
  50. 62 0
      tests/Unit/WorldCupReviewRepositoryMailCopyTest.php
  51. 252 0
      tests/Unit/WorldCupReviewServiceTest.php
  52. 268 0
      tests/Unit/WorldCupScheduleUpdateServiceTest.php
  53. 19 0
      tests/Unit/WorldCupSettlementAdminViewTest.php
  54. 229 0
      tests/Unit/WorldCupSettlementServiceTest.php

+ 28 - 0
app/Console/Commands/WorldCupGenerateMissingReferralRewards.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\WorldCup\WorldCupReferralRewardService;
+use Illuminate\Console\Command;
+
+class WorldCupGenerateMissingReferralRewards extends Command
+{
+    protected $signature = 'world-cup:generate-missing-referral-rewards {--limit=200}';
+
+    protected $description = 'Generate missing World Cup referral rewards from paid first-deposit orders';
+
+    public function handle(WorldCupReferralRewardService $service)
+    {
+        $limit = max(1, (int)$this->option('limit'));
+        $result = $service->generateMissingRewards($limit);
+
+        $this->info(sprintf(
+            'checked=%d created=%d skipped=%d',
+            $result['checked'],
+            $result['created'],
+            $result['skipped']
+        ));
+
+        return 0;
+    }
+}

+ 2 - 0
app/Console/Kernel.php

@@ -19,6 +19,7 @@ use App\Console\Commands\RecordThreeGameYesterday;
 use App\Console\Commands\RecordPaidRewardDailyStatistics;
 use App\Console\Commands\RecordUserScoreChangeStatistics;
 use App\Console\Commands\SuperballUpdatePoolAndStats;
+use App\Console\Commands\WorldCupGenerateMissingReferralRewards;
 use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
 
@@ -47,6 +48,7 @@ class Kernel extends ConsoleKernel
         DbQueue::class,
         RecordThreeGameYesterday::class,
         SuperballUpdatePoolAndStats::class,
+        WorldCupGenerateMissingReferralRewards::class,
 
     ];
 

+ 218 - 0
app/Http/Controllers/Admin/WorldCupMarketController.php

@@ -0,0 +1,218 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Services\WorldCup\WorldCupBetService;
+use App\Services\WorldCup\WorldCupOddsService;
+use App\Services\WorldCup\WorldCupScheduleUpdateService;
+use App\Services\WorldCup\WorldCupSettlementService;
+use Illuminate\Http\Request;
+
+class WorldCupMarketController extends BaseController
+{
+    private $oddsService;
+
+    private $betService;
+
+    private $scheduleService;
+
+    private $settlementService;
+
+    public function __construct(
+        WorldCupOddsService $oddsService,
+        WorldCupBetService $betService,
+        WorldCupScheduleUpdateService $scheduleService,
+        WorldCupSettlementService $settlementService
+    ) {
+        $this->oddsService = $oddsService;
+        $this->betService = $betService;
+        $this->scheduleService = $scheduleService;
+        $this->settlementService = $settlementService;
+    }
+
+    public function odds(Request $request)
+    {
+        $filters = [
+            'market' => (string)$request->input('market', ''),
+            'match_id' => (string)$request->input('match_id', ''),
+            'limit' => (int)$request->input('limit', 300),
+        ];
+
+        if ($this->wantsJson($request)) {
+            return $this->json(200, 'success', [
+                'list' => $this->oddsService->listOdds($filters),
+            ]);
+        }
+
+        return view('admin.world_cup.odds', [
+            'filters' => $filters,
+            'odds' => $this->oddsService->listOdds($filters),
+            'matches' => $this->scheduleService->allMatches(),
+            'result' => $request->session()->get('world_cup_odds_result'),
+        ]);
+    }
+
+    public function saveOdds(Request $request)
+    {
+        $result = $this->oddsService->saveOdds([
+            'market' => (string)$request->input('market', ''),
+            'match_id' => $request->input('match_id') === '' ? null : $request->input('match_id'),
+            'selection' => (string)$request->input('selection', ''),
+            'decimal_odds' => (float)$request->input('decimal_odds', 0),
+            'is_active' => (int)$request->input('is_active', 0),
+            'locked_weight' => (int)$request->input('locked_weight', 0),
+        ]);
+
+        return $this->respond($request, $result, '/admin/world-cup/odds', 'world_cup_odds_result');
+    }
+
+    public function importOdds(Request $request)
+    {
+        $rows = $this->parseCsvRows($request);
+        $result = $rows === null
+            ? [
+                'success' => false,
+                'message' => 'Invalid CSV file',
+                'data' => [
+                    'updated' => 0,
+                    'skipped' => 0,
+                    'errors' => [],
+                ],
+            ]
+            : $this->oddsService->importOddsRows(
+                (string)$request->input('market', ''),
+                $rows
+            );
+
+        return $this->respond($request, $result, '/admin/world-cup/odds', 'world_cup_odds_result');
+    }
+
+    public function bets(Request $request)
+    {
+        $result = $this->betService->adminBetLogs([
+            'game_id' => $request->input('game_id', ''),
+            'user_id' => $request->input('user_id', ''),
+            'status' => $request->input('status', ''),
+            'market' => $request->input('market', ''),
+            'limit' => (int)$request->input('limit', 100),
+        ]);
+
+        if ($this->wantsJson($request)) {
+            return $this->json(200, 'success', [
+                'list' => $result['list'],
+            ]);
+        }
+
+        return view('admin.world_cup.bets', [
+            'filters' => $result['filters'],
+            'list' => $result['list'],
+        ]);
+    }
+
+    public function settlement(Request $request)
+    {
+        if ($this->wantsJson($request)) {
+            return $this->json(200, 'success', [
+                'matches' => $this->scheduleService->allMatches(),
+            ]);
+        }
+
+        return view('admin.world_cup.settlement', [
+            'matches' => $this->scheduleService->allMatches(),
+            'result' => $request->session()->get('world_cup_settlement_result'),
+        ]);
+    }
+
+    public function settleMatch(Request $request)
+    {
+        $result = $this->settlementService->settleMatch(
+            (int)$request->input('match_id', 0),
+            (string)$request->input('result', ''),
+            $this->actor($request)
+        );
+
+        return $this->respond($request, $result, '/admin/world-cup/settlement', 'world_cup_settlement_result');
+    }
+
+    public function settleWinner(Request $request)
+    {
+        $result = $this->settlementService->settleWinner(
+            (string)$request->input('selection', ''),
+            $this->actor($request)
+        );
+
+        return $this->respond($request, $result, '/admin/world-cup/settlement', 'world_cup_settlement_result');
+    }
+
+    private function respond(Request $request, array $result, string $url, string $sessionKey)
+    {
+        if ($this->wantsJson($request)) {
+            return $this->json($result['success'] ? 200 : 400, $result['message'] ?: 'success', $result['data']);
+        }
+
+        return redirect($url)->with($sessionKey, $result);
+    }
+
+    private function actor(Request $request): string
+    {
+        return (string)($request->session()->get('admin.username') ?: 'admin');
+    }
+
+    private function wantsJson(Request $request): bool
+    {
+        return $request->ajax()
+            || $request->expectsJson()
+            || $request->input('format') === 'json';
+    }
+
+    private function parseCsvRows(Request $request): ?array
+    {
+        if (!$request->hasFile('csv_file') || !$request->file('csv_file')->isValid()) {
+            return null;
+        }
+
+        $handle = fopen($request->file('csv_file')->getRealPath(), 'r');
+        if ($handle === false) {
+            return null;
+        }
+
+        $headers = null;
+        $rows = [];
+
+        while (($data = fgetcsv($handle)) !== false) {
+            if ($headers === null) {
+                $headers = $this->normalizeCsvHeaders($data);
+                continue;
+            }
+
+            if ($this->isEmptyCsvRow($data)) {
+                continue;
+            }
+
+            $values = array_slice(array_pad($data, count($headers), ''), 0, count($headers));
+            $rows[] = array_combine($headers, $values);
+        }
+
+        fclose($handle);
+
+        return $headers === null ? null : $rows;
+    }
+
+    private function normalizeCsvHeaders(array $headers): array
+    {
+        return array_map(function ($header) {
+            return trim(str_replace("\xEF\xBB\xBF", '', (string)$header));
+        }, $headers);
+    }
+
+    private function isEmptyCsvRow(array $row): bool
+    {
+        foreach ($row as $value) {
+            if (trim((string)$value) !== '') {
+                return false;
+            }
+        }
+
+        return true;
+    }
+}

+ 175 - 0
app/Http/Controllers/Admin/WorldCupReviewController.php

@@ -0,0 +1,175 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Services\WorldCup\WorldCupReviewService;
+use Illuminate\Http\Request;
+
+class WorldCupReviewController extends BaseController
+{
+    private $service;
+
+    public function __construct(WorldCupReviewService $service)
+    {
+        $this->service = $service;
+    }
+
+    public function rewards(Request $request)
+    {
+        $filters = [
+            'status' => $request->input('status', 'reviewing'),
+            'risk' => $request->input('risk', ''),
+            'q' => $request->input('q', ''),
+            'limit' => (int)$request->input('limit', 100),
+        ];
+
+        if (!$this->wantsJson($request)) {
+            return view('admin.world_cup.rewards', [
+                'filters' => $filters,
+                'kpi' => $this->service->kpi(),
+                'list' => $this->service->queue($filters),
+                'logs' => $this->service->auditLogs(30),
+            ]);
+        }
+
+        return $this->json(200, 'success', [
+            'list' => $this->service->queue($filters),
+        ]);
+    }
+
+    public function reward($rewardId)
+    {
+        $list = $this->service->queue([
+            'q' => (string)$rewardId,
+            'limit' => 1,
+        ]);
+
+        if (!$list) {
+            return $this->json(404, 'Reward not found');
+        }
+
+        return $this->json(200, 'success', [
+            'reward' => $list[0],
+        ]);
+    }
+
+    public function approve(Request $request, $rewardId)
+    {
+        return $this->responseResult($this->service->approve((int)$rewardId, $this->actor($request)));
+    }
+
+    public function reject(Request $request, $rewardId)
+    {
+        return $this->responseResult($this->service->reject(
+            (int)$rewardId,
+            $this->actor($request),
+            (string)$request->input('reason_code', '')
+        ));
+    }
+
+    public function hold(Request $request, $rewardId)
+    {
+        return $this->responseResult($this->service->hold((int)$rewardId, $this->actor($request)));
+    }
+
+    public function clawback(Request $request, $rewardId)
+    {
+        return $this->responseResult($this->service->clawback(
+            (int)$rewardId,
+            $this->actor($request),
+            (string)$request->input('reason_code', ''),
+            (bool)$request->input('ban_users', false)
+        ));
+    }
+
+    public function batch(Request $request)
+    {
+        $ids = $request->input('reward_ids', []);
+        if (!is_array($ids)) {
+            $ids = [];
+        }
+
+        $action = (string)$request->input('action', '');
+        if ($action === 'approve') {
+            return $this->responseResult($this->service->batchApprove($ids, $this->actor($request)));
+        }
+
+        if ($action === 'reject') {
+            return $this->responseResult($this->service->batchReject(
+                $ids,
+                $this->actor($request),
+                (string)$request->input('reason_code', '')
+            ));
+        }
+
+        return $this->json(400, 'Invalid batch action');
+    }
+
+    public function kpi(Request $request)
+    {
+        $kpi = $this->service->kpi();
+
+        if (!$this->wantsJson($request)) {
+            return view('admin.world_cup.kpi', [
+                'kpi' => $kpi,
+                'highRiskList' => $this->service->queue([
+                    'status' => 'reviewing',
+                    'risk' => 'high',
+                    'q' => '',
+                    'limit' => 20,
+                ]),
+                'reviewingList' => $this->service->queue([
+                    'status' => 'reviewing',
+                    'risk' => '',
+                    'q' => '',
+                    'limit' => 10,
+                ]),
+                'logs' => $this->service->auditLogs(20),
+            ]);
+        }
+
+        return $this->json(200, 'success', $kpi);
+    }
+
+    public function logs(Request $request)
+    {
+        $filters = [
+            'reward_id' => $request->input('reward_id', ''),
+            'actor' => $request->input('actor', ''),
+            'action' => $request->input('action', ''),
+            'limit' => (int)$request->input('limit', 100),
+        ];
+
+        if (!$this->wantsJson($request)) {
+            return view('admin.world_cup.logs', [
+                'filters' => $filters,
+                'logs' => $this->service->auditLogs($filters),
+            ]);
+        }
+
+        return $this->json(200, 'success', [
+            'list' => $this->service->auditLogs($filters),
+        ]);
+    }
+
+    private function actor(Request $request): string
+    {
+        return (string)($request->session()->get('admin.username') ?: 'admin');
+    }
+
+    private function responseResult(array $result)
+    {
+        if (!$result['success']) {
+            return $this->json(400, $result['message']);
+        }
+
+        return $this->json(200, 'success', $result['data']);
+    }
+
+    private function wantsJson(Request $request): bool
+    {
+        return $request->ajax()
+            || $request->expectsJson()
+            || $request->input('format') === 'json';
+    }
+}

+ 163 - 0
app/Http/Controllers/Admin/WorldCupScheduleController.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Services\WorldCup\WorldCupScheduleUpdateService;
+use Illuminate\Http\Request;
+
+class WorldCupScheduleController extends BaseController
+{
+    private $service;
+
+    public function __construct(WorldCupScheduleUpdateService $service)
+    {
+        $this->service = $service;
+    }
+
+    public function index(Request $request)
+    {
+        $scheduleDate = (string)$request->input('schedule_date', '');
+        $matches = $scheduleDate !== ''
+            ? $this->service->matchesByDate($scheduleDate)
+            : $this->service->allMatches();
+
+        return view('admin.world_cup.schedule', [
+            'scheduleDate' => $scheduleDate,
+            'matches' => $matches,
+            'result' => $request->session()->get('world_cup_schedule_update_result'),
+            'scheduleUrls' => $this->scheduleUrls(),
+        ]);
+    }
+
+    public function update(Request $request)
+    {
+        $scheduleDate = (string)$request->input('schedule_date', '');
+        $matches = $this->parseMatches($request);
+
+        if ($matches === null) {
+            $result = [
+                'success' => false,
+                'message' => 'Invalid matches payload',
+                'data' => [
+                    'updated' => 0,
+                    'skipped' => 0,
+                    'errors' => [],
+                ],
+            ];
+
+            return $this->respond($request, $result, $scheduleDate);
+        }
+
+        $result = $this->service->updateMatches($matches, $this->actor($request));
+
+        return $this->respond($request, $result, $scheduleDate);
+    }
+
+    public function import(Request $request)
+    {
+        $rows = $this->parseCsvRows($request);
+        $result = $rows === null
+            ? [
+                'success' => false,
+                'message' => 'Invalid CSV file',
+                'data' => [
+                    'updated' => 0,
+                    'skipped' => 0,
+                    'errors' => [],
+                ],
+            ]
+            : $this->service->importExistingMatches($rows, $this->actor($request));
+
+        return $this->respond($request, $result, (string)$request->input('schedule_date', ''));
+    }
+
+    private function parseMatches(Request $request): ?array
+    {
+        $matches = $request->input('matches');
+        if (is_array($matches)) {
+            return $matches;
+        }
+
+        return [];
+    }
+
+    private function respond(Request $request, array $result, string $scheduleDate)
+    {
+        if ($request->ajax() || $request->expectsJson() || $request->input('format') === 'json') {
+            return $this->json($result['success'] ? 200 : 400, $result['message'], $result['data']);
+        }
+
+        $url = '/admin/world-cup/schedule';
+        if ($scheduleDate !== '') {
+            $url .= '?schedule_date=' . urlencode($scheduleDate);
+        }
+
+        return redirect($url)
+            ->with('world_cup_schedule_update_result', $result);
+    }
+
+    private function actor(Request $request): string
+    {
+        return (string)($request->session()->get('admin.username') ?: 'admin');
+    }
+
+    private function scheduleUrls(): array
+    {
+        return [
+            'FIFA 官方赛程' => 'https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/articles/match-schedule-fixtures-results-teams-stadiums',
+            'FIFA 赛程更新时间公告' => 'https://vod.fifa.com/media-releases/updated-world-cup-2026-match-schedule-venues-kick-off-times-104-matches',
+            '可读赛程 PDF' => 'https://www.kickoffclock.com/downloads/world-cup-2026-schedule.pdf',
+        ];
+    }
+
+    private function parseCsvRows(Request $request): ?array
+    {
+        if (!$request->hasFile('csv_file') || !$request->file('csv_file')->isValid()) {
+            return null;
+        }
+
+        $handle = fopen($request->file('csv_file')->getRealPath(), 'r');
+        if ($handle === false) {
+            return null;
+        }
+
+        $headers = null;
+        $rows = [];
+
+        while (($data = fgetcsv($handle)) !== false) {
+            if ($headers === null) {
+                $headers = $this->normalizeCsvHeaders($data);
+                continue;
+            }
+
+            if ($this->isEmptyCsvRow($data)) {
+                continue;
+            }
+
+            $values = array_slice(array_pad($data, count($headers), ''), 0, count($headers));
+            $rows[] = array_combine($headers, $values);
+        }
+
+        fclose($handle);
+
+        return $headers === null ? null : $rows;
+    }
+
+    private function normalizeCsvHeaders(array $headers): array
+    {
+        return array_map(function ($header) {
+            return trim(str_replace("\xEF\xBB\xBF", '', (string)$header));
+        }, $headers);
+    }
+
+    private function isEmptyCsvRow(array $row): bool
+    {
+        foreach ($row as $value) {
+            if (trim((string)$value) !== '') {
+                return false;
+            }
+        }
+
+        return true;
+    }
+}

+ 231 - 0
app/Http/Controllers/Game/WorldCupActivityController.php

@@ -0,0 +1,231 @@
+<?php
+
+namespace App\Http\Controllers\Game;
+
+use App\Http\Controllers\Controller;
+use App\Services\WorldCup\WorldCupBetService;
+use App\Services\WorldCup\WorldCupMatchFavoriteService;
+use App\Services\WorldCup\WorldCupOddsService;
+use App\Services\WorldCup\WorldCupReferralRewardService;
+use App\Services\WorldCup\WorldCupReferralService;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+
+class WorldCupActivityController extends Controller
+{
+    private $betService;
+
+    private $favoriteService;
+
+    private $referralService;
+
+    private $referralRewardService;
+
+    private $oddsService;
+
+    public function __construct(
+        WorldCupBetService $betService,
+        WorldCupReferralService $referralService,
+        WorldCupMatchFavoriteService $favoriteService,
+        WorldCupReferralRewardService $referralRewardService,
+        WorldCupOddsService $oddsService
+    ) {
+        $this->betService = $betService;
+        $this->referralService = $referralService;
+        $this->favoriteService = $favoriteService;
+        $this->referralRewardService = $referralRewardService;
+        $this->oddsService = $oddsService;
+    }
+
+    public function info(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        $balance = $this->getUserBalance((int)$user->UserID);
+        $firstBetUsed = $this->isFirstBetUsed((int)$user->UserID);
+        $inviteState = $this->referralRewardService->getInviteState((int)$user->UserID);
+
+        return apiReturnSuc([
+            'invite_code' => $inviteState['invite_code'],
+            'referral_bound' => $inviteState['referral_bound'],
+            'referred_by_user_id' => $inviteState['referred_by_user_id'],
+            'invite_copy' => $this->referralService->getInviteCopy(),
+            'bet_panel' => $this->betService->buildBetPanelState($balance, $firstBetUsed),
+            'first_bet_used' => $firstBetUsed,
+            'limits' => [
+                'min_stake' => WorldCupBetService::MIN_STAKE,
+                'first_stake_cap' => WorldCupBetService::FIRST_STAKE_CAP,
+                'max_stake' => WorldCupBetService::MAX_STAKE,
+                'referral_cap_each' => WorldCupReferralService::REFERRAL_CAP,
+            ],
+            'register_timestmap' => strtotime($user->RegisterDate),
+            'server_time' => time(),
+        ]);
+    }
+
+    public function bindInvite(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        $result = $this->referralRewardService->bindInvite(
+            (int)$user->UserID,
+            (string)$request->input('invite_code', ''),
+            (string)$request->input('bind_type', 'manual')
+        );
+
+        if (!$result['success']) {
+            return apiReturnFail($result['message']);
+        }
+
+        return apiReturnSuc([
+            'status' => $result['status'],
+            'referral' => $result['data'],
+        ]);
+    }
+
+    public function inviteLog(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        return apiReturnSuc($this->referralRewardService->inviteLog(
+            (int)$user->UserID,
+            (string)$request->input('type', 'invited'),
+            (int)$request->input('limit', 20)
+        ));
+    }
+
+    public function rewardLog(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        return apiReturnSuc($this->referralRewardService->rewardLog(
+            (int)$user->UserID,
+            (int)$request->input('limit', 20)
+        ));
+    }
+
+    public function matches(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        $favoriteOnly = (int)$request->input('favorite', 0) === 1;
+
+        return apiReturnSuc([
+            'matches' => $this->favoriteService->listMatches((int)$user->UserID, $favoriteOnly),
+        ]);
+    }
+
+    public function toggleFavorite(Request $request, $matchId)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        $result = $this->favoriteService->toggleFavorite((int)$user->UserID, (int)$matchId);
+        if (!$result['success']) {
+            return apiReturnFail($result['message']);
+        }
+
+        return apiReturnSuc([
+            'match_id' => (int)$matchId,
+            'is_favorite' => $result['is_favorite'],
+        ]);
+    }
+
+    public function betPanelState(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        $balance = $this->getUserBalance((int)$user->UserID);
+        $firstBetUsed = $this->isFirstBetUsed((int)$user->UserID);
+
+        return apiReturnSuc($this->betService->buildBetPanelState($balance, $firstBetUsed));
+    }
+
+    public function betLog(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        return apiReturnSuc($this->betService->betLog(
+            (int)$user->UserID,
+            (string)$request->input('status', 'all'),
+            (int)$request->input('limit', 20)
+        ));
+    }
+
+    public function placeBet(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        $result = $this->betService->placeBet([
+            'user_id' => (int)$user->UserID,
+            'game_id' => (int)($user->GameID ?? 0),
+            'market' => (string)$request->input('market', '1x2'),
+            'match_id' => $request->input('match_id'),
+            'selection' => (string)$request->input('selection', ''),
+            'stake' => (int)$request->input('stake', 0),
+            'idempotency_key' => (string)$request->input('idempotency_key', ''),
+        ]);
+
+        if (!$result['success']) {
+            return apiReturnFail($result['message']);
+        }
+
+        return apiReturnSuc([
+            'status' => $result['status'],
+            'bet' => $result['data'],
+        ]);
+    }
+
+    public function winnerMarkets(Request $request)
+    {
+        $user = $request->user();
+        if (!$user) {
+            return apiReturnFail(['web.activity.login_required', 'Please login first']);
+        }
+
+        return apiReturnSuc([
+            'markets' => $this->oddsService->winnerOdds(),
+        ]);
+    }
+
+    private function getUserBalance(int $userId): int
+    {
+        return (int)DB::connection('read')->table('QPTreasureDB.dbo.GameScoreInfo')
+            ->where('UserID', $userId)
+            ->value('Score');
+    }
+
+    private function isFirstBetUsed(int $userId): bool
+    {
+        return DB::connection('read')->table('agent.dbo.world_cup_user_state')
+            ->where('user_id', $userId)
+            ->where('first_bet_used', 1)
+            ->exists();
+    }
+}

+ 35 - 0
app/Listeners/GenerateWorldCupReferralRewardOnOrderPaid.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Listeners;
+
+use App\Events\OrderPaid;
+use App\Services\WorldCup\WorldCupReferralRewardService;
+use Illuminate\Support\Facades\Log;
+
+class GenerateWorldCupReferralRewardOnOrderPaid
+{
+    private $service;
+
+    public function __construct(WorldCupReferralRewardService $service = null)
+    {
+        $this->service = $service ?: new WorldCupReferralRewardService();
+    }
+
+    public function handle(OrderPaid $event): void
+    {
+        try {
+            $this->service->handleFirstDeposit(
+                (int)$event->userId,
+                (float)$event->payAmt,
+                (string)$event->orderSn
+            );
+        } catch (\Exception $exception) {
+            Log::error('GenerateWorldCupReferralRewardOnOrderPaid: exception', [
+                'user_id' => $event->userId,
+                'pay_amt' => $event->payAmt,
+                'order_sn' => $event->orderSn,
+                'error' => $exception->getMessage(),
+            ]);
+        }
+    }
+}

+ 2 - 0
app/Providers/EventServiceProvider.php

@@ -6,6 +6,7 @@ use App\Events\OrderCreated;
 use App\Events\OrderPaid;
 use App\Listeners\AddCryptoBonusOnOrderPaid;
 use App\Listeners\BindCouponToOrder;
+use App\Listeners\GenerateWorldCupReferralRewardOnOrderPaid;
 use App\Listeners\ProcessCouponOnOrderPaid;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Auth\Events\Registered;
@@ -29,6 +30,7 @@ class EventServiceProvider extends ServiceProvider
         OrderPaid::class => [
             ProcessCouponOnOrderPaid::class,
             AddCryptoBonusOnOrderPaid::class,
+            GenerateWorldCupReferralRewardOnOrderPaid::class,
         ],
     ];
 

+ 309 - 0
app/Services/WorldCup/Repositories/SqlWorldCupBetRepository.php

@@ -0,0 +1,309 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use App\Facade\TableName;
+use App\Utility\SetNXLock;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\DB;
+
+class SqlWorldCupBetRepository implements WorldCupBetRepositoryInterface
+{
+    public const BET_TABLE = 'world_cup_bets';
+    public const MATCH_TABLE = 'world_cup_matches';
+    public const ODDS_TABLE = 'world_cup_odds';
+    public const USER_STATE_TABLE = 'world_cup_user_state';
+    public const USER_BET_LOCK_TTL = 5;
+
+    public function findBetByIdempotencyKey(int $userId, string $idempotencyKey): ?array
+    {
+        return $this->findBetByIdempotencyKeyUsingConnection('read', $userId, $idempotencyKey);
+    }
+
+    private function findBetByIdempotencyKeyUsingConnection(
+        string $connection,
+        int $userId,
+        string $idempotencyKey
+    ): ?array
+    {
+        $query = DB::connection($connection)->table(TableName::agent() . self::BET_TABLE);
+        if ($connection === 'read') {
+            $query->lock('WITH (NOLOCK)');
+        }
+
+        $bet = $query
+            ->where('user_id', $userId)
+            ->where('idempotency_key', $idempotencyKey)
+            ->first();
+
+        return $bet ? (array)$bet : null;
+    }
+
+    public function findMatch(int $matchId): ?array
+    {
+        $match = DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('match_id', $matchId)
+            ->first();
+
+        return $match ? (array)$match : null;
+    }
+
+    public function findActiveOdds(string $market, ?int $matchId, string $selection): ?array
+    {
+        $query = DB::connection('read')->table(TableName::agent() . self::ODDS_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('market', $market)
+            ->where('selection', $selection)
+            ->where('is_active', 1);
+
+        if ($matchId === null) {
+            $query->whereNull('match_id');
+        } else {
+            $query->where('match_id', $matchId);
+        }
+
+        $odds = $query->first();
+
+        return $odds ? (array)$odds : null;
+    }
+
+    public function getBalance(int $userId): int
+    {
+        return (int)DB::connection('read')->table(TableName::QPTreasureDB() . 'GameScoreInfo')
+            ->lock('WITH (NOLOCK)')
+            ->where('UserID', $userId)
+            ->value('Score');
+    }
+
+    public function isFirstBetUsed(int $userId): bool
+    {
+        return DB::connection('read')->table(TableName::agent() . self::USER_STATE_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('user_id', $userId)
+            ->where('first_bet_used', 1)
+            ->exists();
+    }
+
+    public function createBetAndDeductBalance(array $bet, bool $markFirstBetUsed): array
+    {
+        $lockKey = $this->userBetLockKey((int)$bet['user_id']);
+        if (!SetNXLock::getExclusiveLock($lockKey, self::USER_BET_LOCK_TTL)) {
+            throw new \RuntimeException('Bet is processing');
+        }
+
+        try {
+            $createdBet = DB::connection('write')->transaction(function () use (
+                $bet,
+                $markFirstBetUsed
+            ) {
+                $existing = $this->findBetByIdempotencyKeyUsingConnection(
+                    'write',
+                    (int)$bet['user_id'],
+                    (string)$bet['idempotency_key']
+                );
+                if ($existing) {
+                    return $existing;
+                }
+
+                $now = date('Y-m-d H:i:s');
+                $payload = array_merge($bet, [
+                    'created_at' => $now,
+                    'updated_at' => $now,
+                ]);
+
+                try {
+                    DB::connection('write')->table(TableName::agent() . self::BET_TABLE)->insert($payload);
+                } catch (QueryException $exception) {
+                    if (!$this->isDuplicateException($exception)) {
+                        throw $exception;
+                    }
+
+                    return $this->findBetByIdempotencyKeyUsingConnection(
+                        'write',
+                        (int)$bet['user_id'],
+                        (string)$bet['idempotency_key']
+                    );
+                }
+
+                $deducted = DB::connection('write')->table(TableName::QPTreasureDB() . 'GameScoreInfo')
+                    ->where('UserID', (int)$bet['user_id'])
+                    ->where('Score', '>=', (int)$bet['stake'])
+                    ->decrement('Score', (int)$bet['stake']);
+
+                if ((int)$deducted === 0) {
+                    throw new \RuntimeException('Insufficient balance');
+                }
+
+                if ($markFirstBetUsed) {
+                    $this->markFirstBetUsed((int)$bet['user_id'], $now);
+                }
+
+                return $this->findBetByIdempotencyKeyUsingConnection(
+                    'write',
+                    (int)$bet['user_id'],
+                    (string)$bet['idempotency_key']
+                );
+            });
+        } finally {
+            SetNXLock::release($lockKey);
+        }
+
+        return $createdBet;
+    }
+
+    public function betLogs(int $userId, string $status, int $limit): array
+    {
+        $query = DB::connection('read')->table(TableName::agent() . self::BET_TABLE . ' as b')
+            ->lock('WITH (NOLOCK)')
+            ->leftJoin(
+                DB::raw(TableName::agent() . self::MATCH_TABLE . ' as m WITH (NOLOCK)'),
+                'm.match_id',
+                '=',
+                'b.match_id'
+            )
+            ->where('b.user_id', $userId)
+            ->select(
+                'b.bet_id',
+                'b.user_id',
+                'b.game_id',
+                'b.market',
+                'b.match_id',
+                'b.selection',
+                'b.stake',
+                'b.odds',
+                'b.is_first_bet',
+                'b.bonus_amount',
+                'b.potential_payout',
+                'b.status',
+                'b.settled_at',
+                'b.created_at',
+                'm.competition',
+                'm.home_team',
+                'm.away_team',
+                'm.stage',
+                'm.group_name'
+            );
+
+        if ($status === 'pending') {
+            $query->where('b.status', 'pending');
+        } elseif ($status === 'settled') {
+            $query->whereIn('b.status', ['won', 'lost']);
+        } elseif ($status === 'won') {
+            $query->where('b.status', 'won');
+        }
+
+        return $query->orderBy('b.created_at', 'desc')
+            ->limit($limit)
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function betLogStats(int $userId): array
+    {
+        $row = DB::connection('read')->table(TableName::agent() . self::BET_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('user_id', $userId)
+            ->selectRaw(
+                "SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count, " .
+                "SUM(CASE WHEN status = 'won' THEN potential_payout ELSE 0 END) as total_won"
+            )
+            ->first();
+
+        return [
+            'pending_count' => (int)($row->pending_count ?? 0),
+            'total_won' => (int)($row->total_won ?? 0),
+        ];
+    }
+
+    public function adminBetLogs(array $filters): array
+    {
+        $query = DB::connection('read')->table(TableName::agent() . self::BET_TABLE . ' as b')
+            ->lock('WITH (NOLOCK)')
+            ->leftJoin(
+                DB::raw(TableName::agent() . self::MATCH_TABLE . ' as m WITH (NOLOCK)'),
+                'm.match_id',
+                '=',
+                'b.match_id'
+            )
+            ->select(
+                'b.bet_id',
+                'b.user_id',
+                'b.game_id',
+                'b.market',
+                'b.match_id',
+                'b.selection',
+                'b.stake',
+                'b.odds',
+                'b.is_first_bet',
+                'b.bonus_amount',
+                'b.potential_payout',
+                'b.status',
+                'b.settled_at',
+                'b.created_at',
+                'm.competition',
+                'm.home_team',
+                'm.away_team',
+                'm.stage',
+                'm.group_name'
+            );
+
+        if (!empty($filters['game_id'])) {
+            $query->where('b.game_id', (int)$filters['game_id']);
+        }
+
+        if (!empty($filters['user_id'])) {
+            $query->where('b.user_id', (int)$filters['user_id']);
+        }
+
+        if (!empty($filters['status'])) {
+            $query->where('b.status', $filters['status']);
+        }
+
+        if (!empty($filters['market'])) {
+            $query->where('b.market', $filters['market']);
+        }
+
+        return $query->orderBy('b.created_at', 'desc')
+            ->limit((int)($filters['limit'] ?? 100))
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    private function markFirstBetUsed(int $userId, string $now): void
+    {
+        $updated = DB::connection('write')->table(TableName::agent() . self::USER_STATE_TABLE)
+            ->where('user_id', $userId)
+            ->update([
+                'first_bet_used' => 1,
+                'updated_at' => $now,
+            ]);
+
+        if ($updated === 0) {
+            DB::connection('write')->table(TableName::agent() . self::USER_STATE_TABLE)->insert([
+                'user_id' => $userId,
+                'first_bet_used' => 1,
+                'invite_code' => 'WC' . strtoupper(base_convert((string)$userId, 10, 36)),
+                'created_at' => $now,
+                'updated_at' => $now,
+            ]);
+        }
+    }
+
+    private function userBetLockKey(int $userId): string
+    {
+        return 'world_cup_bet_user:' . $userId;
+    }
+
+    private function isDuplicateException(QueryException $exception): bool
+    {
+        return stripos($exception->getMessage(), 'duplicate') !== false
+            || stripos($exception->getMessage(), 'unique') !== false;
+    }
+}

+ 104 - 0
app/Services/WorldCup/Repositories/SqlWorldCupMatchRepository.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use App\Facade\TableName;
+use Carbon\Carbon;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\DB;
+
+class SqlWorldCupMatchRepository implements WorldCupMatchRepositoryInterface
+{
+    public const MATCH_TABLE = 'world_cup_matches';
+    public const FAVORITE_TABLE = 'world_cup_match_favorites';
+
+    public function getScheduleMatches(Carbon $now): array
+    {
+        $cutoff = $now->copy()->addHour()->format('Y-m-d H:i:s');
+
+        return DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('kickoff_at', '>', $cutoff)
+            ->whereIn('status', ['scheduled', 'closed'])
+            ->orderBy('kickoff_at')
+            ->orderBy('match_no')
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function getOpenMatches(Carbon $now): array
+    {
+        $cutoff = $now->copy()->addHour()->format('Y-m-d H:i:s');
+
+        return DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('status', 'scheduled')
+            ->where('kickoff_at', '>', $cutoff)
+            ->orderBy('kickoff_at')
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function isOpenMatch(int $matchId, Carbon $now): bool
+    {
+        $cutoff = $now->copy()->addHour()->format('Y-m-d H:i:s');
+
+        return DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('match_id', $matchId)
+            ->where('status', 'scheduled')
+            ->where('kickoff_at', '>', $cutoff)
+            ->exists();
+    }
+
+    public function getFavoriteMatchIds(int $userId): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::FAVORITE_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('user_id', $userId)
+            ->pluck('match_id')
+            ->map(function ($matchId) {
+                return (int)$matchId;
+            })
+            ->all();
+    }
+
+    public function isFavorite(int $userId, int $matchId): bool
+    {
+        return DB::connection('read')->table(TableName::agent() . self::FAVORITE_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('user_id', $userId)
+            ->where('match_id', $matchId)
+            ->exists();
+    }
+
+    public function addFavorite(int $userId, int $matchId): void
+    {
+        try {
+            DB::connection('write')->table(TableName::agent() . self::FAVORITE_TABLE)->insert([
+                'user_id' => $userId,
+                'match_id' => $matchId,
+                'created_at' => date('Y-m-d H:i:s'),
+            ]);
+        } catch (QueryException $exception) {
+            if (stripos($exception->getMessage(), 'duplicate') === false
+                && stripos($exception->getMessage(), 'unique') === false) {
+                throw $exception;
+            }
+        }
+    }
+
+    public function removeFavorite(int $userId, int $matchId): void
+    {
+        DB::connection('write')->table(TableName::agent() . self::FAVORITE_TABLE)
+            ->where('user_id', $userId)
+            ->where('match_id', $matchId)
+            ->delete();
+    }
+}

+ 125 - 0
app/Services/WorldCup/Repositories/SqlWorldCupOddsRepository.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use App\Facade\TableName;
+use Illuminate\Support\Facades\DB;
+
+class SqlWorldCupOddsRepository implements WorldCupOddsRepositoryInterface
+{
+    public const ODDS_TABLE = 'world_cup_odds';
+
+    public const MATCH_TABLE = 'world_cup_matches';
+
+    public function allOdds(array $filters = []): array
+    {
+        $oddsTable = TableName::agent() . self::ODDS_TABLE;
+        $matchTable = TableName::agent() . self::MATCH_TABLE;
+        $query = DB::connection('read')->table($oddsTable . ' as odds')
+            ->lock('WITH (NOLOCK)')
+            ->leftJoin(DB::raw($matchTable . ' as matches WITH (NOLOCK)'), 'matches.match_id', '=', 'odds.match_id')
+            ->select([
+                'odds.*',
+                'matches.home_team',
+                'matches.away_team',
+            ]);
+
+        if (!empty($filters['market'])) {
+            $query->where('odds.market', $filters['market']);
+        }
+
+        if (!empty($filters['match_id'])) {
+            $query->where('odds.match_id', (int)$filters['match_id']);
+        }
+
+        return $query->orderBy('odds.market')
+            ->orderBy('odds.match_id')
+            ->orderBy('odds.locked_weight', 'desc')
+            ->orderBy('odds.selection')
+            ->limit((int)($filters['limit'] ?? 300))
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function activeMatchOdds(array $matchIds): array
+    {
+        if (!$matchIds) {
+            return [];
+        }
+
+        return DB::connection('read')->table(TableName::agent() . self::ODDS_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('market', '1x2')
+            ->where('is_active', 1)
+            ->whereIn('match_id', array_map('intval', $matchIds))
+            ->orderBy('match_id')
+            ->orderByRaw("CASE selection WHEN 'home' THEN 1 WHEN 'draw' THEN 2 WHEN 'away' THEN 3 ELSE 4 END")
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function activeWinnerOdds(): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::ODDS_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('market', 'winner')
+            ->where('is_active', 1)
+            ->orderBy('locked_weight', 'desc')
+            ->orderBy('decimal_odds')
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function findOdds(string $market, ?int $matchId, string $selection): ?array
+    {
+        $query = DB::connection('read')->table(TableName::agent() . self::ODDS_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('market', $market)
+            ->where('selection', $selection);
+
+        if ($matchId === null) {
+            $query->whereNull('match_id');
+        } else {
+            $query->where('match_id', $matchId);
+        }
+
+        $odds = $query->first();
+
+        return $odds ? (array)$odds : null;
+    }
+
+    public function upsertOdds(array $odds): array
+    {
+        $now = date('Y-m-d H:i:s');
+        $query = DB::connection('write')->table(TableName::agent() . self::ODDS_TABLE)
+            ->where('market', $odds['market'])
+            ->where('selection', $odds['selection']);
+
+        if ($odds['match_id'] === null) {
+            $query->whereNull('match_id');
+        } else {
+            $query->where('match_id', (int)$odds['match_id']);
+        }
+
+        $payload = array_merge($odds, [
+            'updated_at' => $now,
+        ]);
+
+        if ($query->exists()) {
+            $query->update($payload);
+        } else {
+            DB::connection('write')->table(TableName::agent() . self::ODDS_TABLE)->insert($payload);
+        }
+
+        return $this->findOdds((string)$odds['market'], $odds['match_id'], (string)$odds['selection']);
+    }
+}

+ 386 - 0
app/Services/WorldCup/Repositories/SqlWorldCupReferralRepository.php

@@ -0,0 +1,386 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use App\Facade\TableName;
+use App\Game\GlobalUserInfo;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\DB;
+
+class SqlWorldCupReferralRepository implements WorldCupReferralRepositoryInterface
+{
+    public const USER_STATE_TABLE = 'world_cup_user_state';
+    public const REFERRAL_TABLE = 'world_cup_referrals';
+    public const REWARD_TABLE = 'world_cup_referral_rewards';
+
+    public function ensureUserState(int $userId): array
+    {
+        $state = $this->findUserState($userId);
+        if ($state) {
+            return $state;
+        }
+
+        $now = date('Y-m-d H:i:s');
+        for ($i = 0; $i < 3; $i++) {
+            $inviteCode = $this->buildInviteCode($userId, $i);
+            try {
+                DB::connection('write')->table(TableName::agent() . self::USER_STATE_TABLE)->insert([
+                    'user_id' => $userId,
+                    'invite_code' => $inviteCode,
+                    'created_at' => $now,
+                    'updated_at' => $now,
+                ]);
+
+                return $this->findUserStateUsingConnection('write', $userId);
+            } catch (QueryException $exception) {
+                if ($this->isDuplicateException($exception)) {
+                    $state = $this->findUserState($userId);
+                    if ($state) {
+                        return $state;
+                    }
+
+                    continue;
+                }
+
+                throw $exception;
+            }
+        }
+
+        throw new \RuntimeException('Unable to create World Cup user state');
+    }
+
+    public function findUserState(int $userId): ?array
+    {
+        return $this->findUserStateUsingConnection('read', $userId);
+    }
+
+    private function findUserStateUsingConnection(string $connection, int $userId): ?array
+    {
+        $query = DB::connection($connection)->table(TableName::agent() . self::USER_STATE_TABLE);
+        if ($connection === 'read') {
+            $query->lock('WITH (NOLOCK)');
+        }
+
+        $state = $query
+            ->where('user_id', $userId)
+            ->first();
+
+        return $state ? (array)$state : null;
+    }
+
+    public function findUserByInviteCode(string $inviteCode): ?array
+    {
+        $state = DB::connection('read')->table(TableName::agent() . self::USER_STATE_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('invite_code', $inviteCode)
+            ->first();
+
+        return $state ? (array)$state : null;
+    }
+
+    public function findReferralByInvitee(int $inviteeId): ?array
+    {
+        $referral = DB::connection('read')->table(TableName::agent() . self::REFERRAL_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('invitee_id', $inviteeId)
+            ->first();
+
+        return $referral ? (array)$referral : null;
+    }
+
+    public function bindReferral(int $referrerId, int $inviteeId, string $bindType): array
+    {
+        $now = date('Y-m-d H:i:s');
+
+        try {
+            DB::connection('write')->transaction(function () use ($referrerId, $inviteeId, $bindType, $now) {
+                DB::connection('write')->table(TableName::agent() . self::REFERRAL_TABLE)->insert([
+                    'referrer_id' => $referrerId,
+                    'invitee_id' => $inviteeId,
+                    'bind_type' => $bindType,
+                    'bind_at' => $now,
+                    'created_at' => $now,
+                ]);
+
+                DB::connection('write')->table(TableName::agent() . self::USER_STATE_TABLE)
+                    ->where('user_id', $inviteeId)
+                    ->update([
+                        'referred_by_user_id' => $referrerId,
+                        'referral_bind_at' => $now,
+                        'referral_bind_type' => $bindType,
+                        'updated_at' => $now,
+                    ]);
+            });
+        } catch (QueryException $exception) {
+            if (!$this->isDuplicateException($exception)) {
+                throw $exception;
+            }
+        }
+
+        $referral = $this->findReferralByInviteeUsingConnection('write', $inviteeId);
+        if (!$referral) {
+            throw new \RuntimeException('Unable to bind World Cup referral');
+        }
+
+        return $referral;
+    }
+
+    public function isFirstSuccessfulOrder(int $userId, string $orderSn): bool
+    {
+        $orderTable = TableName::agent() . 'order';
+        $currentOrderSuccessful = DB::connection('read')->table($orderTable)
+            ->lock('WITH (NOLOCK)')
+            ->where('user_id', $userId)
+            ->where('order_sn', $orderSn)
+            ->where('pay_status', 1)
+            ->exists();
+
+        if (!$currentOrderSuccessful) {
+            return !DB::connection('read')->table($orderTable)
+                ->lock('WITH (NOLOCK)')
+                ->where('user_id', $userId)
+                ->where('pay_status', 1)
+                ->exists();
+        }
+
+        $firstOrderSn = DB::connection('read')->table($orderTable)
+            ->lock('WITH (NOLOCK)')
+            ->where('user_id', $userId)
+            ->where('pay_status', 1)
+            ->orderBy('pay_at')
+            ->orderBy('id')
+            ->value('order_sn');
+
+        return (string)$firstOrderSn === $orderSn;
+    }
+
+    public function findRewardByInvitee(int $inviteeId): ?array
+    {
+        return $this->findRewardByInviteeUsingConnection('read', $inviteeId);
+    }
+
+    private function findRewardByInviteeUsingConnection(string $connection, int $inviteeId): ?array
+    {
+        $query = DB::connection($connection)->table(TableName::agent() . self::REWARD_TABLE);
+        if ($connection === 'read') {
+            $query->lock('WITH (NOLOCK)');
+        }
+
+        $reward = $query
+            ->where('invitee_id', $inviteeId)
+            ->first();
+
+        return $reward ? (array)$reward : null;
+    }
+
+    public function createReward(array $reward): array
+    {
+        $now = date('Y-m-d H:i:s');
+        $payload = array_merge($reward, [
+            'submitted_at' => $now,
+            'created_at' => $now,
+            'updated_at' => $now,
+        ]);
+
+        try {
+            DB::connection('write')->table(TableName::agent() . self::REWARD_TABLE)->insert($payload);
+        } catch (QueryException $exception) {
+            if (!$this->isDuplicateException($exception)) {
+                throw $exception;
+            }
+        }
+
+        $created = $this->findRewardByInviteeUsingConnection('write', (int)$reward['invitee_id']);
+        if (!$created) {
+            throw new \RuntimeException('Unable to create World Cup referral reward');
+        }
+
+        return $created;
+    }
+
+    private function findReferralByInviteeUsingConnection(string $connection, int $inviteeId): ?array
+    {
+        $query = DB::connection($connection)->table(TableName::agent() . self::REFERRAL_TABLE);
+        if ($connection === 'read') {
+            $query->lock('WITH (NOLOCK)');
+        }
+
+        $referral = $query
+            ->where('invitee_id', $inviteeId)
+            ->first();
+
+        return $referral ? (array)$referral : null;
+    }
+
+    public function paidOrdersMissingRewards(int $limit): array
+    {
+        return DB::connection('read')->table(TableName::agent() . 'order as o')
+            ->lock('WITH (NOLOCK)')
+            ->join(
+                DB::raw(TableName::agent() . self::REFERRAL_TABLE . ' as r WITH (NOLOCK)'),
+                'r.invitee_id',
+                '=',
+                'o.user_id'
+            )
+            ->leftJoin(
+                DB::raw(TableName::agent() . self::REWARD_TABLE . ' as rw WITH (NOLOCK)'),
+                'rw.invitee_id',
+                '=',
+                'o.user_id'
+            )
+            ->where('o.pay_status', 1)
+            ->whereNull('rw.reward_id')
+            ->select('o.user_id', 'o.amount', 'o.order_sn')
+            ->orderBy('o.pay_at')
+            ->limit($limit)
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function inviteLogs(int $referrerId, string $type, int $limit): array
+    {
+        $query = DB::connection('read')->table(TableName::agent() . self::REFERRAL_TABLE . ' as r')
+            ->lock('WITH (NOLOCK)')
+            ->leftJoin(
+                DB::raw(TableName::agent() . self::REWARD_TABLE . ' as rw WITH (NOLOCK)'),
+                'rw.invitee_id',
+                '=',
+                'r.invitee_id'
+            )
+            ->leftJoin(
+                DB::raw(TableName::QPAccountsDB() . 'AccountsInfo as ai WITH (NOLOCK)'),
+                'ai.UserID',
+                '=',
+                'r.invitee_id'
+            )
+            ->where('r.referrer_id', $referrerId)
+            ->select(
+                'r.referrer_id',
+                'r.invitee_id',
+                'r.bind_type',
+                'r.bind_at',
+                'ai.GameID as game_id',
+                'rw.first_deposit_amt',
+                'rw.reward_each',
+                'rw.status as reward_status',
+                'rw.submitted_at',
+                'ai.FaceID'
+            );
+
+        if ($type === 'deposits') {
+            $query->whereNotNull('rw.reward_id');
+        }
+
+        return $query->orderBy('r.bind_at', 'desc')
+            ->limit($limit)
+            ->get()
+            ->map(function ($row) {
+                $item = (array)$row;
+                $item['referrer_id'] = (int)$item['referrer_id'];
+                $item['invitee_id'] = (int)$item['invitee_id'];
+                $item['game_id'] = isset($item['game_id']) ? (int)$item['game_id'] : $item['invitee_id'];
+                $item['first_deposit_amt'] = (int)($item['first_deposit_amt'] ?? 0);
+                $item['reward_each'] = (int)($item['reward_each'] ?? 0);
+                $item['status'] = $item['first_deposit_amt'] > 0 ? 'deposited' : 'awaiting';
+                $item['avatar'] = GlobalUserInfo::faceidToAvatar($item['FaceID'] ?? 0);
+
+                return $item;
+            })
+            ->all();
+    }
+
+    public function inviteStats(int $referrerId): array
+    {
+        $row = DB::connection('read')->table(TableName::agent() . self::REFERRAL_TABLE . ' as r')
+            ->lock('WITH (NOLOCK)')
+            ->leftJoin(
+                DB::raw(TableName::agent() . self::REWARD_TABLE . ' as rw WITH (NOLOCK)'),
+                'rw.invitee_id',
+                '=',
+                'r.invitee_id'
+            )
+            ->where('r.referrer_id', $referrerId)
+            ->selectRaw(
+                'COUNT(1) as invited_count, ' .
+                'SUM(CASE WHEN rw.reward_id IS NULL THEN 0 ELSE 1 END) as deposited_count'
+            )
+            ->first();
+
+        $invited = (int)($row->invited_count ?? 0);
+        $deposited = (int)($row->deposited_count ?? 0);
+
+        return [
+            'invited_count' => $invited,
+            'deposited_count' => $deposited,
+            'awaiting_count' => max(0, $invited - $deposited),
+        ];
+    }
+
+    public function rewardLogs(int $userId, int $limit): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::REWARD_TABLE . ' as rw')
+            ->lock('WITH (NOLOCK)')
+            ->leftJoin(
+                DB::raw(TableName::QPAccountsDB() . 'AccountsInfo as invitee_ai WITH (NOLOCK)'),
+                'invitee_ai.UserID',
+                '=',
+                'rw.invitee_id'
+            )
+            ->where('rw.referrer_id', $userId)
+            ->select(
+                'rw.*',
+                'invitee_ai.FaceID as invitee_face_id'
+            )
+            ->orderBy('rw.submitted_at', 'desc')
+            ->limit($limit)
+            ->get()
+            ->map(function ($row) {
+                $item = (array)$row;
+                $item['reward_id'] = (int)$item['reward_id'];
+                $item['referrer_id'] = (int)$item['referrer_id'];
+                $item['invitee_id'] = (int)$item['invitee_id'];
+                $item['friend_user_id'] = $item['invitee_id'];
+                $item['avatar'] = GlobalUserInfo::faceidToAvatar((int)($item['invitee_face_id'] ?? 0));
+                $item['first_deposit_amt'] = (int)$item['first_deposit_amt'];
+                $item['reward_each'] = (int)$item['reward_each'];
+                $item['total_liability'] = (int)$item['total_liability'];
+                $item['status'] = $item['status'] === 'approved' ? 'paid' : $item['status'];
+
+                return $item;
+            })
+            ->all();
+    }
+
+    public function rewardStats(int $userId): array
+    {
+        $row = DB::connection('read')->table(TableName::agent() . self::REWARD_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('referrer_id', $userId)
+            ->selectRaw(
+                "SUM(CASE WHEN status IN ('reviewing', 'on_hold') THEN reward_each ELSE 0 END) as reviewing_amount, " .
+                "SUM(CASE WHEN status = 'approved' THEN reward_each ELSE 0 END) as paid_amount"
+            )
+            ->first();
+
+        return [
+            'reviewing_amount' => (int)($row->reviewing_amount ?? 0),
+            'paid_amount' => (int)($row->paid_amount ?? 0),
+        ];
+    }
+
+    private function buildInviteCode(int $userId, int $salt): string
+    {
+        $suffix = $salt === 0 ? '' : (string)$salt;
+
+        return 'WC' . strtoupper(base_convert((string)$userId, 10, 36)) . $suffix;
+    }
+
+    private function isDuplicateException(QueryException $exception): bool
+    {
+        return stripos($exception->getMessage(), 'duplicate') !== false
+            || stripos($exception->getMessage(), 'unique') !== false;
+    }
+}

+ 310 - 0
app/Services/WorldCup/Repositories/SqlWorldCupReviewRepository.php

@@ -0,0 +1,310 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use App\Facade\TableName;
+use App\Models\PrivateMail;
+use Illuminate\Support\Facades\DB;
+
+class SqlWorldCupReviewRepository implements WorldCupReviewRepositoryInterface
+{
+    public const REWARD_TABLE = 'world_cup_referral_rewards';
+    public const AUDIT_TABLE = 'world_cup_audit_log';
+
+    public function findReward(int $rewardId): ?array
+    {
+        $reward = DB::connection('read')->table(TableName::agent() . self::REWARD_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('reward_id', $rewardId)
+            ->first();
+
+        return $reward ? (array)$reward : null;
+    }
+
+    public function findRewards(array $rewardIds): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::REWARD_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->whereIn('reward_id', array_map('intval', $rewardIds))
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function updateRewardStatus(
+        int $rewardId,
+        string $status,
+        string $actor,
+        string $reasonCode = null
+    ): void {
+        DB::connection('write')->table(TableName::agent() . self::REWARD_TABLE)
+            ->where('reward_id', $rewardId)
+            ->update([
+                'status' => $status,
+                'reason_code' => $reasonCode,
+                'review_by' => $actor,
+                'reviewed_at' => date('Y-m-d H:i:s'),
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+    }
+
+    public function payReward(array $reward): void
+    {
+        DB::connection('write')->transaction(function () use ($reward) {
+            $amount = (int)$reward['reward_each'];
+            $referrerId = (int)$reward['referrer_id'];
+            $inviteeId = (int)$reward['invitee_id'];
+
+            PrivateMail::sendMail(
+                2,
+                $referrerId,
+                $this->approvedReferrerMailTitle($reward),
+                $this->approvedReferrerMailText($reward),
+                '30000,' . $amount,
+                'world_cup_reward_' . $reward['reward_id'] . '_' . $referrerId,
+                $amount,
+                2
+            );
+
+            PrivateMail::sendMail(
+                2,
+                $inviteeId,
+                $this->approvedInviteeMailTitle($reward),
+                $this->approvedInviteeMailText($reward),
+                '30000,' . $amount,
+                'world_cup_reward_' . $reward['reward_id'] . '_' . $inviteeId,
+                $amount,
+                2
+            );
+        });
+    }
+
+    public function sendRejectMail(array $reward, string $reason): void
+    {
+        $referrerId = (int)$reward['referrer_id'];
+        $inviteeId = (int)$reward['invitee_id'];
+
+        PrivateMail::sendMail(
+            2,
+            $referrerId,
+            $this->rejectedReferrerMailTitle($reward),
+            $this->rejectedReferrerMailText($reward, $reason),
+            '',
+            'world_cup_reward_reject_' . $reward['reward_id'] . '_' . $referrerId,
+            0,
+            2
+        );
+
+        PrivateMail::sendMail(
+            2,
+            $inviteeId,
+            $this->rejectedInviteeMailTitle($reward),
+            $this->rejectedInviteeMailText($reward, $reason),
+            '',
+            'world_cup_reward_reject_' . $reward['reward_id'] . '_' . $inviteeId,
+            0,
+            2
+        );
+    }
+
+    public function clawbackReward(array $reward, bool $banUsers): void
+    {
+        DB::connection('write')->transaction(function () use ($reward, $banUsers) {
+            $amount = (int)$reward['reward_each'];
+            $userIds = [(int)$reward['referrer_id'], (int)$reward['invitee_id']];
+
+            foreach ($userIds as $userId) {
+                DB::connection('write')->table(TableName::QPTreasureDB() . 'GameScoreInfo')
+                    ->where('UserID', $userId)
+                    ->decrement('Score', $amount);
+            }
+
+            if ($banUsers) {
+                DB::connection('write')->table(TableName::QPAccountsDB() . 'AccountsInfo')
+                    ->whereIn('UserID', $userIds)
+                    ->update(['Nullity' => 1]);
+            }
+        });
+    }
+
+    public function writeAudit(
+        int $rewardId,
+        string $actor,
+        string $action,
+        ?string $reasonCode,
+        ?string $beforeStatus,
+        ?string $afterStatus,
+        array $payload = []
+    ): void {
+        DB::connection('write')->table(TableName::agent() . self::AUDIT_TABLE)->insert([
+            'reward_id' => $rewardId,
+            'actor' => $actor,
+            'action' => $action,
+            'reason_code' => $reasonCode,
+            'before_status' => $beforeStatus,
+            'after_status' => $afterStatus,
+            'payload' => $payload ? json_encode($payload) : null,
+            'created_at' => date('Y-m-d H:i:s'),
+        ]);
+    }
+
+    public function queue(array $filters): array
+    {
+        $query = DB::connection('read')->table(TableName::agent() . self::REWARD_TABLE)
+            ->lock('WITH (NOLOCK)');
+
+        if (!empty($filters['status'])) {
+            $query->where('status', $filters['status']);
+        }
+
+        if (!empty($filters['risk'])) {
+            $query->where('risk_level', $filters['risk']);
+        }
+
+        if (!empty($filters['q'])) {
+            $q = trim((string)$filters['q']);
+            $query->where(function ($inner) use ($q) {
+                $inner->where('referrer_id', $q)
+                    ->orWhere('invitee_id', $q)
+                    ->orWhere('reward_id', $q);
+            });
+        }
+
+        return $query->orderByRaw("CASE WHEN risk_level = 'high' THEN 0 ELSE 1 END")
+            ->orderBy('submitted_at')
+            ->limit((int)($filters['limit'] ?? 100))
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function kpi(): array
+    {
+        $rows = DB::connection('read')->table(TableName::agent() . self::REWARD_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->selectRaw('status, COUNT(1) as total_count, ISNULL(SUM(total_liability), 0) as total_liability')
+            ->groupBy('status')
+            ->get();
+
+        $result = [
+            'reviewing_count' => 0,
+            'approved_count' => 0,
+            'rejected_count' => 0,
+            'paid_liability' => 0,
+        ];
+
+        foreach ($rows as $row) {
+            if ($row->status === 'reviewing') {
+                $result['reviewing_count'] = (int)$row->total_count;
+            } elseif ($row->status === 'approved') {
+                $result['approved_count'] = (int)$row->total_count;
+                $result['paid_liability'] = (int)$row->total_liability;
+            } elseif ($row->status === 'rejected') {
+                $result['rejected_count'] = (int)$row->total_count;
+            }
+        }
+
+        return $result;
+    }
+
+    public function auditLogs($filters): array
+    {
+        if (is_int($filters)) {
+            $filters = ['limit' => $filters];
+        }
+
+        $query = DB::connection('read')->table(TableName::agent() . self::AUDIT_TABLE)
+            ->lock('WITH (NOLOCK)');
+
+        if (!empty($filters['reward_id'])) {
+            $query->where('reward_id', (int)$filters['reward_id']);
+        }
+
+        if (!empty($filters['actor'])) {
+            $query->where('actor', 'like', '%' . trim((string)$filters['actor']) . '%');
+        }
+
+        if (!empty($filters['action'])) {
+            $query->where('action', trim((string)$filters['action']));
+        }
+
+        return $query->orderBy('created_at', 'desc')
+            ->limit((int)($filters['limit'] ?? 100))
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    private function approvedReferrerMailTitle(array $reward): string
+    {
+        return 'Claim your ' . $this->moneyText((int)$reward['reward_each']) . ' referral reward';
+    }
+
+    private function approvedReferrerMailText(array $reward): string
+    {
+        $rewardText = $this->moneyText((int)$reward['reward_each']);
+        $firstDepositText = $this->moneyText((int)$reward['first_deposit_amt']);
+
+        return 'Your referral reward is ready to claim.' . "\n"
+            . 'Friend: ID ' . (int)$reward['invitee_id']
+            . ' · first deposit ' . $firstDepositText
+            . ' Reward: ' . $rewardText
+            . ' (50% of their first deposit). Tap Claim to add it to your balance.';
+    }
+
+    private function approvedInviteeMailTitle(array $reward): string
+    {
+        return 'Claim your ' . $this->moneyText((int)$reward['reward_each']) . ' welcome reward';
+    }
+
+    private function approvedInviteeMailText(array $reward): string
+    {
+        $rewardText = $this->moneyText((int)$reward['reward_each']);
+        $firstDepositText = $this->moneyText((int)$reward['first_deposit_amt']);
+
+        return 'Welcome! Your referral reward is ready to claim. '
+            . 'You signed up with an invite and made your first deposit of ' . $firstDepositText . '. '
+            . 'Reward: ' . $rewardText . ' (50% of your first deposit). '
+            . 'Tap Claim to add it to your balance.';
+    }
+
+    private function rejectedReferrerMailTitle(array $reward): string
+    {
+        return 'Your referral reward could not be approved';
+    }
+
+    private function rejectedReferrerMailText(array $reward, string $reason): string
+    {
+        return 'The referral reward for inviting ID ' . (int)$reward['invitee_id']
+            . ' was reviewed and could not be approved.' . "\n"
+            . 'Reason: ' . $reason . '.' . "\n"
+            . 'If you believe this is a mistake, please contact support.';
+    }
+
+    private function rejectedInviteeMailTitle(array $reward): string
+    {
+        return 'Welcome reward not approved';
+    }
+
+    private function rejectedInviteeMailText(array $reward, string $reason): string
+    {
+        return 'Your welcome reward could not be approved.' . "\n"
+            . 'Reason: ' . $reason . '.' . "\n"
+            . 'If you think this is a mistake, contact support.';
+    }
+
+    private function moneyText(int $amount): string
+    {
+        $value = $amount / 100;
+
+        return floor($value) == $value
+            ? '$' . number_format($value, 0)
+            : '$' . number_format($value, 2);
+    }
+}

+ 105 - 0
app/Services/WorldCup/Repositories/SqlWorldCupScheduleRepository.php

@@ -0,0 +1,105 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use App\Facade\TableName;
+use Illuminate\Support\Facades\DB;
+
+class SqlWorldCupScheduleRepository implements WorldCupScheduleRepositoryInterface
+{
+    public const MATCH_TABLE = 'world_cup_matches';
+    public const AUDIT_TABLE = 'world_cup_audit_log';
+
+    public function allMatches(): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->orderBy('match_no')
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function matchesByDate(string $scheduleDate): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->whereRaw('CONVERT(date, kickoff_at) = ?', [$scheduleDate])
+            ->orderBy('match_no')
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function unresolvedMatchesByDate(string $scheduleDate): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->whereRaw('CONVERT(date, kickoff_at) = ?', [$scheduleDate])
+            ->where(function ($query) {
+                $query->where('home_team', 'like', 'Winner %')
+                    ->orWhere('away_team', 'like', 'Winner %');
+            })
+            ->orderBy('match_no')
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function updateMatchByNoAndDate(
+        int $matchNo,
+        string $scheduleDate,
+        array $attributes
+    ): bool {
+        $attributes['updated_at'] = date('Y-m-d H:i:s');
+
+        $affected = DB::connection('write')->table(TableName::agent() . self::MATCH_TABLE)
+            ->where('match_no', $matchNo)
+            ->whereRaw('CONVERT(date, kickoff_at) = ?', [$scheduleDate])
+            ->update($attributes);
+
+        return $affected > 0;
+    }
+
+    public function updateMatchByNo(int $matchNo, array $attributes): bool
+    {
+        $attributes['updated_at'] = date('Y-m-d H:i:s');
+
+        $affected = DB::connection('write')->table(TableName::agent() . self::MATCH_TABLE)
+            ->where('match_no', $matchNo)
+            ->update($attributes);
+
+        return $affected > 0;
+    }
+
+    public function updateMatchById(int $matchId, array $attributes): bool
+    {
+        $attributes['updated_at'] = date('Y-m-d H:i:s');
+
+        $affected = DB::connection('write')->table(TableName::agent() . self::MATCH_TABLE)
+            ->where('match_id', $matchId)
+            ->update($attributes);
+
+        return $affected > 0;
+    }
+
+    public function writeScheduleAudit(string $actor, string $action, array $payload): void
+    {
+        DB::connection('write')->table(TableName::agent() . self::AUDIT_TABLE)->insert([
+            'reward_id' => null,
+            'actor' => $actor,
+            'action' => $action,
+            'reason_code' => null,
+            'before_status' => null,
+            'after_status' => null,
+            'payload' => json_encode($payload),
+            'created_at' => date('Y-m-d H:i:s'),
+        ]);
+    }
+}

+ 214 - 0
app/Services/WorldCup/Repositories/SqlWorldCupSettlementRepository.php

@@ -0,0 +1,214 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use App\Facade\TableName;
+use App\Models\PrivateMail;
+use Illuminate\Support\Facades\DB;
+
+class SqlWorldCupSettlementRepository implements WorldCupSettlementRepositoryInterface
+{
+    public const BET_TABLE = 'world_cup_bets';
+    public const MATCH_TABLE = 'world_cup_matches';
+    public const AUDIT_TABLE = 'world_cup_audit_log';
+
+    public function findMatch(int $matchId): ?array
+    {
+        $match = DB::connection('read')->table(TableName::agent() . self::MATCH_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('match_id', $matchId)
+            ->first();
+
+        return $match ? (array)$match : null;
+    }
+
+    public function markMatchFinished(int $matchId, string $result): void
+    {
+        DB::connection('write')->table(TableName::agent() . self::MATCH_TABLE)
+            ->where('match_id', $matchId)
+            ->where('status', '!=', 'finished')
+            ->update([
+                'status' => 'finished',
+                'result' => $result,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+    }
+
+    public function pendingBetsForMatch(int $matchId): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::BET_TABLE . ' as b')
+            ->lock('WITH (NOLOCK)')
+            ->leftJoin(
+                DB::raw(TableName::agent() . self::MATCH_TABLE . ' as m WITH (NOLOCK)'),
+                'm.match_id',
+                '=',
+                'b.match_id'
+            )
+            ->where('b.market', '1x2')
+            ->where('b.match_id', $matchId)
+            ->where('b.status', 'pending')
+            ->select(
+                'b.*',
+                'm.competition',
+                'm.stage',
+                'm.group_name',
+                'm.home_team',
+                'm.away_team'
+            )
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function pendingWinnerBets(): array
+    {
+        return DB::connection('read')->table(TableName::agent() . self::BET_TABLE)
+            ->lock('WITH (NOLOCK)')
+            ->where('market', 'winner')
+            ->where('status', 'pending')
+            ->get()
+            ->map(function ($row) {
+                return (array)$row;
+            })
+            ->all();
+    }
+
+    public function markBetSettled(int $betId, string $status): void
+    {
+        DB::connection('write')->table(TableName::agent() . self::BET_TABLE)
+            ->where('bet_id', $betId)
+            ->where('status', 'pending')
+            ->update([
+                'status' => $status,
+                'settled_at' => date('Y-m-d H:i:s'),
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+    }
+
+    public function payBet(array $bet): int
+    {
+        return (int)DB::connection('write')->transaction(function () use ($bet) {
+            $updated = DB::connection('write')->table(TableName::agent() . self::BET_TABLE)
+                ->where('bet_id', (int)$bet['bet_id'])
+                ->where('status', 'pending')
+                ->update([
+                    'status' => 'won',
+                    'settled_at' => date('Y-m-d H:i:s'),
+                    'updated_at' => date('Y-m-d H:i:s'),
+                ]);
+
+            if ($updated === 1) {
+                $mail = $this->buildRewardMail($bet);
+                PrivateMail::sendMail(
+                    2,
+                    (int)$bet['user_id'],
+                    $mail['title'],
+                    $mail['text'],
+                    '30000,' . (int)$bet['potential_payout'],
+                    'world_cup_bet_' . (int)$bet['bet_id'],
+                    (int)$bet['potential_payout'],
+                    2
+                );
+
+                return (int)$bet['potential_payout'];
+            }
+
+            return 0;
+        });
+    }
+
+    private function buildRewardMail(array $bet): array
+    {
+        $payout = $this->moneyText((int)$bet['potential_payout']);
+
+        if (($bet['market'] ?? '') === 'winner') {
+            $team = (string)$bet['selection'];
+
+            return [
+                'title' => 'You won ' . $payout . ' · World Cup 2026 Winner',
+                'text' => implode("\n", [
+                    'You won!',
+                    'Market: World Cup 2026 Winner',
+                    'Your bet: ' . $team . ' to win · ' . $this->moneyText((int)$bet['stake'])
+                        . ' · Odds ' . number_format((float)$bet['odds'], 2),
+                    'Result: ' . $team . ' champion — you won ' . $payout,
+                ]),
+            ];
+        }
+
+        $matchName = ($bet['home_team'] ?? 'Home') . ' vs ' . ($bet['away_team'] ?? 'Away');
+        $stage = $this->stageLabel($bet);
+        $selection = $this->selectionLabel($bet);
+
+        return [
+            'title' => 'You won ' . $payout . ' · ' . $matchName,
+            'text' => implode("\n", [
+                'You won!',
+                'Match: ' . $stage . ' · ' . $matchName,
+                'Your bet: ' . $selection . ' · ' . $this->moneyText((int)$bet['stake'])
+                    . ' · Odds ' . number_format((float)$bet['odds'], 2),
+                'Result: ' . $selection . ' — you won ' . $payout,
+            ]),
+        ];
+    }
+
+    private function stageLabel(array $bet): string
+    {
+        if (($bet['stage'] ?? '') === 'group') {
+            return 'Group ' . (string)($bet['group_name'] ?? '');
+        }
+
+        $labels = [
+            'round_32' => 'Round of 32 (32->16)',
+            'round_16' => 'Round of 16 (16->8)',
+            'quarter_final' => 'Quarter-final (8->4)',
+            'semi_final' => 'Semi-final (4->2)',
+            'third_place' => 'Third place',
+            'final' => 'Final',
+        ];
+
+        return $labels[$bet['stage'] ?? ''] ?? (string)($bet['stage'] ?? 'World Cup');
+    }
+
+    private function selectionLabel(array $bet): string
+    {
+        if (($bet['selection'] ?? '') === 'draw') {
+            return 'Draw';
+        }
+
+        if (($bet['selection'] ?? '') === 'home') {
+            return ($bet['home_team'] ?? 'Home') . ' win';
+        }
+
+        if (($bet['selection'] ?? '') === 'away') {
+            return ($bet['away_team'] ?? 'Away') . ' win';
+        }
+
+        return (string)($bet['selection'] ?? '');
+    }
+
+    private function moneyText(int $amount): string
+    {
+        $value = $amount / 100;
+
+        return floor($value) == $value
+            ? '$' . number_format($value, 0)
+            : '$' . number_format($value, 2);
+    }
+
+    public function writeAudit(string $actor, string $action, array $payload): void
+    {
+        DB::connection('write')->table(TableName::agent() . self::AUDIT_TABLE)->insert([
+            'reward_id' => null,
+            'actor' => $actor,
+            'action' => $action,
+            'reason_code' => null,
+            'before_status' => null,
+            'after_status' => null,
+            'payload' => json_encode($payload),
+            'created_at' => date('Y-m-d H:i:s'),
+        ]);
+    }
+}

+ 24 - 0
app/Services/WorldCup/Repositories/WorldCupBetRepositoryInterface.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+interface WorldCupBetRepositoryInterface
+{
+    public function findBetByIdempotencyKey(int $userId, string $idempotencyKey): ?array;
+
+    public function findMatch(int $matchId): ?array;
+
+    public function findActiveOdds(string $market, ?int $matchId, string $selection): ?array;
+
+    public function getBalance(int $userId): int;
+
+    public function isFirstBetUsed(int $userId): bool;
+
+    public function createBetAndDeductBalance(array $bet, bool $markFirstBetUsed): array;
+
+    public function betLogs(int $userId, string $status, int $limit): array;
+
+    public function betLogStats(int $userId): array;
+
+    public function adminBetLogs(array $filters): array;
+}

+ 22 - 0
app/Services/WorldCup/Repositories/WorldCupMatchRepositoryInterface.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+use Carbon\Carbon;
+
+interface WorldCupMatchRepositoryInterface
+{
+    public function getScheduleMatches(Carbon $now): array;
+
+    public function getOpenMatches(Carbon $now): array;
+
+    public function isOpenMatch(int $matchId, Carbon $now): bool;
+
+    public function getFavoriteMatchIds(int $userId): array;
+
+    public function isFavorite(int $userId, int $matchId): bool;
+
+    public function addFavorite(int $userId, int $matchId): void;
+
+    public function removeFavorite(int $userId, int $matchId): void;
+}

+ 16 - 0
app/Services/WorldCup/Repositories/WorldCupOddsRepositoryInterface.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+interface WorldCupOddsRepositoryInterface
+{
+    public function allOdds(array $filters = []): array;
+
+    public function activeMatchOdds(array $matchIds): array;
+
+    public function activeWinnerOdds(): array;
+
+    public function findOdds(string $market, ?int $matchId, string $selection): ?array;
+
+    public function upsertOdds(array $odds): array;
+}

+ 32 - 0
app/Services/WorldCup/Repositories/WorldCupReferralRepositoryInterface.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+interface WorldCupReferralRepositoryInterface
+{
+    public function ensureUserState(int $userId): array;
+
+    public function findUserState(int $userId): ?array;
+
+    public function findUserByInviteCode(string $inviteCode): ?array;
+
+    public function findReferralByInvitee(int $inviteeId): ?array;
+
+    public function bindReferral(int $referrerId, int $inviteeId, string $bindType): array;
+
+    public function isFirstSuccessfulOrder(int $userId, string $orderSn): bool;
+
+    public function findRewardByInvitee(int $inviteeId): ?array;
+
+    public function createReward(array $reward): array;
+
+    public function paidOrdersMissingRewards(int $limit): array;
+
+    public function inviteLogs(int $referrerId, string $type, int $limit): array;
+
+    public function inviteStats(int $referrerId): array;
+
+    public function rewardLogs(int $userId, int $limit): array;
+
+    public function rewardStats(int $userId): array;
+}

+ 39 - 0
app/Services/WorldCup/Repositories/WorldCupReviewRepositoryInterface.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+interface WorldCupReviewRepositoryInterface
+{
+    public function findReward(int $rewardId): ?array;
+
+    public function findRewards(array $rewardIds): array;
+
+    public function updateRewardStatus(
+        int $rewardId,
+        string $status,
+        string $actor,
+        string $reasonCode = null
+    ): void;
+
+    public function payReward(array $reward): void;
+
+    public function sendRejectMail(array $reward, string $reason): void;
+
+    public function clawbackReward(array $reward, bool $banUsers): void;
+
+    public function writeAudit(
+        int $rewardId,
+        string $actor,
+        string $action,
+        ?string $reasonCode,
+        ?string $beforeStatus,
+        ?string $afterStatus,
+        array $payload = []
+    ): void;
+
+    public function queue(array $filters): array;
+
+    public function kpi(): array;
+
+    public function auditLogs($filters): array;
+}

+ 24 - 0
app/Services/WorldCup/Repositories/WorldCupScheduleRepositoryInterface.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+interface WorldCupScheduleRepositoryInterface
+{
+    public function allMatches(): array;
+
+    public function matchesByDate(string $scheduleDate): array;
+
+    public function unresolvedMatchesByDate(string $scheduleDate): array;
+
+    public function updateMatchByNoAndDate(
+        int $matchNo,
+        string $scheduleDate,
+        array $attributes
+    ): bool;
+
+    public function updateMatchByNo(int $matchNo, array $attributes): bool;
+
+    public function updateMatchById(int $matchId, array $attributes): bool;
+
+    public function writeScheduleAudit(string $actor, string $action, array $payload): void;
+}

+ 20 - 0
app/Services/WorldCup/Repositories/WorldCupSettlementRepositoryInterface.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Services\WorldCup\Repositories;
+
+interface WorldCupSettlementRepositoryInterface
+{
+    public function findMatch(int $matchId): ?array;
+
+    public function markMatchFinished(int $matchId, string $result): void;
+
+    public function pendingBetsForMatch(int $matchId): array;
+
+    public function pendingWinnerBets(): array;
+
+    public function markBetSettled(int $betId, string $status): void;
+
+    public function payBet(array $bet): int;
+
+    public function writeAudit(string $actor, string $action, array $payload): void;
+}

+ 359 - 0
app/Services/WorldCup/WorldCupBetService.php

@@ -0,0 +1,359 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupBetRepository;
+use App\Services\WorldCup\Repositories\WorldCupBetRepositoryInterface;
+use Carbon\Carbon;
+
+class WorldCupBetService
+{
+    public const MIN_STAKE = 500;
+    public const FIRST_STAKE_CAP = 1000;
+    public const MAX_STAKE = 100000;
+
+    public const MIN_BET_MESSAGE = 'Min bet $5';
+    public const FIRST_BET_RULE_MESSAGE = 'First bet $5–$10 · +50% bonus · whole numbers only';
+    public const NORMAL_BET_RULE_MESSAGE = 'Single bet $5–$1000 · whole numbers · no payout cap';
+    public const INSUFFICIENT_BALANCE_MESSAGE = 'Insufficient balance';
+    public const BET_PROCESSING_MESSAGE = 'Bet is processing';
+
+    private $repository;
+
+    public function __construct(WorldCupBetRepositoryInterface $repository = null)
+    {
+        $this->repository = $repository ?: new SqlWorldCupBetRepository();
+    }
+
+    public function buildBetPanelState(int $balance, bool $firstBetUsed): array
+    {
+        $cap = $this->stakeCap($firstBetUsed);
+        $defaultStake = min(1000, $cap, $this->floorToWholeDollar($balance));
+        $confirmDisabled = $defaultStake < self::MIN_STAKE || $defaultStake > $balance;
+
+        return [
+            'can_open' => true,
+            'default_stake' => $defaultStake,
+            'confirm_disabled' => $confirmDisabled,
+            'button_text' => $confirmDisabled ? self::MIN_BET_MESSAGE : $this->placeBetText($defaultStake),
+            'rule_text' => $firstBetUsed ? self::NORMAL_BET_RULE_MESSAGE : self::FIRST_BET_RULE_MESSAGE,
+        ];
+    }
+
+    public function validateStake(int $stake, int $balance, bool $firstBetUsed): array
+    {
+        $cap = $this->stakeCap($firstBetUsed);
+
+        if ($stake < self::MIN_STAKE) {
+            return $this->fail(self::MIN_BET_MESSAGE);
+        }
+
+        if ($stake % 100 !== 0) {
+            return $this->fail($firstBetUsed ? self::NORMAL_BET_RULE_MESSAGE : self::FIRST_BET_RULE_MESSAGE);
+        }
+
+        if ($stake > $cap) {
+            return $this->fail($firstBetUsed ? self::NORMAL_BET_RULE_MESSAGE : self::FIRST_BET_RULE_MESSAGE);
+        }
+
+        if ($stake > $balance) {
+            return $this->fail(self::INSUFFICIENT_BALANCE_MESSAGE);
+        }
+
+        return [
+            'success' => true,
+            'message' => '',
+        ];
+    }
+
+    public function placeBet(array $payload, Carbon $now = null): array
+    {
+        $now = $now ?: Carbon::now();
+        $userId = (int)($payload['user_id'] ?? 0);
+        $market = (string)($payload['market'] ?? '');
+        $matchId = isset($payload['match_id']) ? (int)$payload['match_id'] : null;
+        $selection = (string)($payload['selection'] ?? '');
+        $stake = (int)($payload['stake'] ?? 0);
+        $idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
+
+        if ($idempotencyKey === '') {
+            return $this->fail('Idempotency key is required');
+        }
+
+        $existing = $this->repository->findBetByIdempotencyKey($userId, $idempotencyKey);
+        if ($existing) {
+            return [
+                'success' => true,
+                'status' => 'exists',
+                'data' => $existing,
+            ];
+        }
+
+        if (!in_array($market, ['1x2', 'winner'], true)) {
+            return $this->fail('Invalid market');
+        }
+
+        if ($market === '1x2') {
+            $match = $matchId ? $this->repository->findMatch($matchId) : null;
+            if (!$this->isMatchBettable($match, $now)) {
+                return $this->fail('Match is not available');
+            }
+        } else {
+            $matchId = null;
+        }
+
+        $odds = $this->repository->findActiveOdds($market, $matchId, $selection);
+        if (!$odds) {
+            return $this->fail('Odds are not available');
+        }
+
+        $balance = $this->repository->getBalance($userId);
+        $firstBetUsed = $this->repository->isFirstBetUsed($userId);
+        $validation = $this->validateStake($stake, $balance, $firstBetUsed);
+        if (!$validation['success']) {
+            return $validation;
+        }
+
+        $isFirstBet = !$firstBetUsed;
+        $decimalOdds = (float)$odds['decimal_odds'];
+        $bonusAmount = $isFirstBet ? (int)round($stake * ($decimalOdds - 1) * 0.5) : 0;
+        $potentialPayout = (int)round($stake * $decimalOdds) + $bonusAmount;
+        try {
+            $bet = $this->repository->createBetAndDeductBalance([
+                'user_id' => $userId,
+                'game_id' => (int)($payload['game_id'] ?? 0),
+                'idempotency_key' => $idempotencyKey,
+                'market' => $market,
+                'match_id' => $matchId,
+                'selection' => $selection,
+                'stake' => $stake,
+                'odds' => $decimalOdds,
+                'is_first_bet' => $isFirstBet ? 1 : 0,
+                'bonus_amount' => $bonusAmount,
+                'potential_payout' => $potentialPayout,
+                'status' => 'pending',
+            ], $isFirstBet);
+        } catch (\RuntimeException $exception) {
+            if ($exception->getMessage() === self::INSUFFICIENT_BALANCE_MESSAGE) {
+                return $this->fail(self::INSUFFICIENT_BALANCE_MESSAGE);
+            }
+
+            if ($exception->getMessage() === self::BET_PROCESSING_MESSAGE) {
+                return $this->fail(self::BET_PROCESSING_MESSAGE);
+            }
+
+            throw $exception;
+        }
+
+        return [
+            'success' => true,
+            'status' => 'created',
+            'data' => $bet,
+        ];
+    }
+
+    public function betLog(int $userId, string $status = 'all', int $limit = 20): array
+    {
+        $status = in_array($status, ['all', 'pending', 'settled', 'won'], true) ? $status : 'all';
+        $limit = $this->normalizeLimit($limit);
+        $logs = array_map(function (array $bet) {
+            return $this->formatBetLogItem($bet);
+        }, $this->repository->betLogs($userId, $status, $limit));
+
+        return [
+            'stats' => $this->repository->betLogStats($userId),
+            'status' => $status,
+            'list' => $logs,
+        ];
+    }
+
+    public function adminBetLogs(array $filters): array
+    {
+        $filters = [
+            'game_id' => trim((string)($filters['game_id'] ?? '')),
+            'user_id' => trim((string)($filters['user_id'] ?? '')),
+            'status' => (string)($filters['status'] ?? ''),
+            'market' => (string)($filters['market'] ?? ''),
+            'limit' => $this->normalizeAdminLimit((int)($filters['limit'] ?? 100)),
+        ];
+
+        if (!in_array($filters['status'], ['', 'pending', 'won', 'lost'], true)) {
+            $filters['status'] = '';
+        }
+
+        if (!in_array($filters['market'], ['', '1x2', 'winner'], true)) {
+            $filters['market'] = '';
+        }
+
+        return [
+            'filters' => $filters,
+            'list' => array_map(function (array $bet) {
+                return $this->formatAdminBetLogItem($bet);
+            }, $this->repository->adminBetLogs($filters)),
+        ];
+    }
+
+    public function stakeCap(bool $firstBetUsed): int
+    {
+        return $firstBetUsed ? self::MAX_STAKE : self::FIRST_STAKE_CAP;
+    }
+
+    private function floorToWholeDollar(int $amount): int
+    {
+        return max(0, (int)floor($amount / 100) * 100);
+    }
+
+    private function placeBetText(int $stake): string
+    {
+        return 'Place Bet $' . number_format($stake / 100, 0);
+    }
+
+    private function fail(string $message): array
+    {
+        return [
+            'success' => false,
+            'message' => $message,
+        ];
+    }
+
+    private function isMatchBettable(?array $match, Carbon $now): bool
+    {
+        if (!$match || ($match['status'] ?? '') !== 'scheduled') {
+            return false;
+        }
+
+        return Carbon::parse($match['kickoff_at'])->gt($now->copy()->addHour());
+    }
+
+    private function formatBetLogItem(array $bet): array
+    {
+        $status = (string)$bet['status'];
+        $bet['bet_id'] = (int)$bet['bet_id'];
+        $bet['user_id'] = (int)$bet['user_id'];
+        $bet['match_id'] = isset($bet['match_id']) ? (int)$bet['match_id'] : null;
+        $bet['stake'] = (int)$bet['stake'];
+        $bet['odds'] = (float)$bet['odds'];
+        $bet['potential_payout'] = (int)$bet['potential_payout'];
+        $bet['display_status'] = $status === 'pending' ? 'in_play' : $status;
+        $bet['display_payout'] = $status === 'lost'
+            ? -1 * $bet['stake']
+            : $bet['potential_payout'];
+        $bet['title'] = $this->betLogTitle($bet);
+        $bet['subtitle'] = $this->betLogSubtitle($bet);
+
+        return $bet;
+    }
+
+    private function formatAdminBetLogItem(array $bet): array
+    {
+        $bet = $this->formatBetLogItem($bet);
+        $bet['game_id'] = (int)($bet['game_id'] ?? 0);
+        $bet['bonus_amount'] = (int)($bet['bonus_amount'] ?? 0);
+        $bet['stake_text'] = $this->moneyText($bet['stake']);
+        $bet['bonus_text'] = $this->moneyText($bet['bonus_amount']);
+        $bet['potential_payout_text'] = $this->moneyText(
+            (string)$bet['status'] === 'lost' ? 0 : $bet['potential_payout']
+        );
+        $bet['status_label'] = $this->adminStatusLabel((string)$bet['status']);
+        $bet['match_label'] = $this->adminMatchLabel($bet);
+        $bet['selection_label'] = $this->adminSelectionLabel($bet);
+        $bet['user_detail_url'] = '/admin/global/id_find?UserID=' . $bet['user_id'];
+
+        return $bet;
+    }
+
+    private function moneyText(int $amount): string
+    {
+        return '$' . number_format($amount / 100, 2);
+    }
+
+    private function adminStatusLabel(string $status): string
+    {
+        $labels = [
+            'pending' => 'Pending',
+            'won' => 'Won',
+            'lost' => 'Lost',
+        ];
+
+        return $labels[$status] ?? $status;
+    }
+
+    private function adminMatchLabel(array $bet): string
+    {
+        if (empty($bet['match_id'])) {
+            return '-';
+        }
+
+        $teams = trim(($bet['home_team'] ?? '') . ' vs ' . ($bet['away_team'] ?? ''));
+        if ($teams === 'vs') {
+            return (string)$bet['match_id'];
+        }
+
+        return $bet['match_id'] . ' (' . $teams . ')';
+    }
+
+    private function adminSelectionLabel(array $bet): string
+    {
+        if (($bet['market'] ?? '') !== '1x2') {
+            return (string)($bet['selection'] ?? '');
+        }
+
+        if (($bet['selection'] ?? '') === 'draw') {
+            return '平局';
+        }
+
+        if (($bet['selection'] ?? '') === 'home') {
+            return ($bet['home_team'] ?? '主队') . ' 胜';
+        }
+
+        if (($bet['selection'] ?? '') === 'away') {
+            return ($bet['away_team'] ?? '客队') . ' 胜';
+        }
+
+        return (string)($bet['selection'] ?? '');
+    }
+
+    private function betLogTitle(array $bet): string
+    {
+        if ($bet['market'] === 'winner') {
+            return (string)$bet['selection'];
+        }
+
+        if ($bet['selection'] === 'draw') {
+            return trim(($bet['home_team'] ?? '') . ' DRAW ' . ($bet['away_team'] ?? ''));
+        }
+
+        return $bet['selection'] === 'home'
+            ? (string)($bet['home_team'] ?? 'Home')
+            : (string)($bet['away_team'] ?? 'Away');
+    }
+
+    private function betLogSubtitle(array $bet): string
+    {
+        if ($bet['market'] === 'winner') {
+            return 'Winner · World Cup';
+        }
+
+        $market = $bet['selection'] === 'draw' ? 'Draw' : 'Win';
+
+        return $market . ' · ' . ($bet['home_team'] ?? 'Home') . ' vs ' . ($bet['away_team'] ?? 'Away');
+    }
+
+    private function normalizeLimit(int $limit): int
+    {
+        if ($limit <= 0) {
+            return 20;
+        }
+
+        return min($limit, 100);
+    }
+
+    private function normalizeAdminLimit(int $limit): int
+    {
+        if ($limit <= 0) {
+            return 100;
+        }
+
+        return min($limit, 500);
+    }
+}

+ 84 - 0
app/Services/WorldCup/WorldCupMatchFavoriteService.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupMatchRepository;
+use App\Services\WorldCup\Repositories\WorldCupMatchRepositoryInterface;
+use Carbon\Carbon;
+
+class WorldCupMatchFavoriteService
+{
+    private $repository;
+
+    private $oddsService;
+
+    public function __construct(
+        WorldCupMatchRepositoryInterface $repository = null,
+        WorldCupOddsService $oddsService = null
+    )
+    {
+        $this->repository = $repository ?: new SqlWorldCupMatchRepository();
+        $this->oddsService = $oddsService ?: ($repository === null ? new WorldCupOddsService() : null);
+    }
+
+    public function listMatches(int $userId, bool $favoriteOnly = false, Carbon $now = null): array
+    {
+        $now = $now ?: Carbon::now();
+        $favoriteIds = $this->repository->getFavoriteMatchIds($userId);
+        $matches = $this->repository->getScheduleMatches($now);
+        $cutoff = $now->copy()->addHour();
+        $oddsByMatch = $this->oddsService
+            ? $this->oddsService->activeMatchOdds(array_map(function (array $match) {
+                return (int)$match['match_id'];
+            }, $matches))
+            : [];
+
+        $matches = array_map(function (array $match) use ($favoriteIds, $cutoff, $oddsByMatch) {
+            $matchId = (int)$match['match_id'];
+            $match['match_id'] = $matchId;
+            $match['is_favorite'] = in_array($matchId, $favoriteIds, true);
+            $match['is_bettable'] = ($match['status'] ?? '') === 'scheduled'
+                && Carbon::parse($match['kickoff_at'])->gt($cutoff);
+            $match['odds'] = $oddsByMatch[$matchId] ?? [];
+
+            return $match;
+        }, $matches);
+
+        if ($favoriteOnly) {
+            $matches = array_filter($matches, function (array $match) {
+                return $match['is_favorite'] === true
+                    && $match['is_bettable'] === true;
+            });
+        }
+
+        return array_values($matches);
+    }
+
+    public function toggleFavorite(int $userId, int $matchId, Carbon $now = null): array
+    {
+        $now = $now ?: Carbon::now();
+
+        if (!$this->repository->isOpenMatch($matchId, $now)) {
+            return [
+                'success' => false,
+                'message' => 'Match is not available',
+            ];
+        }
+
+        if ($this->repository->isFavorite($userId, $matchId)) {
+            $this->repository->removeFavorite($userId, $matchId);
+
+            return [
+                'success' => true,
+                'is_favorite' => false,
+            ];
+        }
+
+        $this->repository->addFavorite($userId, $matchId);
+
+        return [
+            'success' => true,
+            'is_favorite' => true,
+        ];
+    }
+}

+ 251 - 0
app/Services/WorldCup/WorldCupOddsService.php

@@ -0,0 +1,251 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupOddsRepository;
+use App\Services\WorldCup\Repositories\WorldCupOddsRepositoryInterface;
+
+class WorldCupOddsService
+{
+    private $repository;
+
+    public function __construct(WorldCupOddsRepositoryInterface $repository = null)
+    {
+        $this->repository = $repository ?: new SqlWorldCupOddsRepository();
+    }
+
+    public function listOdds(array $filters = []): array
+    {
+        return array_map(function (array $row) {
+            return $this->formatOdds($row);
+        }, $this->repository->allOdds($filters));
+    }
+
+    public function winnerOdds(): array
+    {
+        return $this->repository->activeWinnerOdds();
+    }
+
+    public function activeMatchOdds(array $matchIds): array
+    {
+        $rows = $this->repository->activeMatchOdds($matchIds);
+        $grouped = [];
+
+        foreach ($rows as $row) {
+            $matchId = (int)$row['match_id'];
+            if (!isset($grouped[$matchId])) {
+                $grouped[$matchId] = [];
+            }
+
+            $grouped[$matchId][] = $this->formatOdds($row);
+        }
+
+        return $grouped;
+    }
+
+    public function saveOdds(array $payload): array
+    {
+        $market = (string)($payload['market'] ?? '');
+        if (!in_array($market, ['1x2', 'winner'], true)) {
+            return $this->fail('Invalid market');
+        }
+
+        $matchId = $market === 'winner' ? null : (int)($payload['match_id'] ?? 0);
+        if ($market === '1x2' && $matchId <= 0) {
+            return $this->fail('Match is required');
+        }
+
+        $selection = trim((string)($payload['selection'] ?? ''));
+        if ($selection === '') {
+            return $this->fail('Selection is required');
+        }
+
+        $decimalOdds = (float)($payload['decimal_odds'] ?? 0);
+        if ($decimalOdds <= 1) {
+            return $this->fail('Decimal odds must be greater than 1');
+        }
+
+        $current = $this->repository->findOdds($market, $matchId, $selection);
+        $odds = $this->repository->upsertOdds([
+            'market' => $market,
+            'match_id' => $matchId,
+            'selection' => $selection,
+            'decimal_odds' => $decimalOdds,
+            'previous_odds' => $current ? (float)$current['decimal_odds'] : null,
+            'is_active' => (int)($payload['is_active'] ?? 1) === 1 ? 1 : 0,
+            'locked_weight' => (int)($payload['locked_weight'] ?? 0),
+        ]);
+
+        return [
+            'success' => true,
+            'message' => '',
+            'data' => $this->formatOdds($odds),
+        ];
+    }
+
+    public function importOddsRows(string $market, array $rows): array
+    {
+        if (!in_array($market, ['1x2', 'winner'], true)) {
+            return $this->fail('Invalid market');
+        }
+
+        $updated = 0;
+        $skipped = 0;
+        $errors = [];
+
+        foreach ($rows as $index => $row) {
+            $payloads = $market === '1x2'
+                ? $this->buildMatchOddsPayloads($row, $index, $errors)
+                : $this->buildWinnerOddsPayloads($row, $index, $errors);
+
+            if (!$payloads) {
+                continue;
+            }
+
+            foreach ($payloads as $payload) {
+                $result = $this->saveOdds($payload);
+                if ($result['success']) {
+                    $updated++;
+                    continue;
+                }
+
+                $errors[] = $this->rowError($index, $result['message']);
+            }
+        }
+
+        return [
+            'success' => $updated > 0,
+            'message' => $updated > 0 ? 'success' : 'No odds imported',
+            'data' => [
+                'updated' => $updated,
+                'skipped' => $skipped,
+                'errors' => $errors,
+            ],
+        ];
+    }
+
+    private function buildMatchOddsPayloads(array $row, int $index, array &$errors): array
+    {
+        $matchId = (int)($row['match_id'] ?? 0);
+        if ($matchId <= 0) {
+            $errors[] = $this->rowError($index, 'match_id is required');
+
+            return [];
+        }
+
+        $payloads = [];
+        foreach (['home' => 'home_win', 'draw' => 'draw', 'away' => 'away_win'] as $selection => $column) {
+            $odds = $this->decimalFromRow($row, $column);
+            if ($odds <= 1) {
+                $errors[] = $this->rowError($index, $column . ' must be greater than 1');
+                continue;
+            }
+
+            $current = $this->repository->findOdds('1x2', $matchId, $selection);
+            $payloads[] = [
+                'market' => '1x2',
+                'match_id' => $matchId,
+                'selection' => $selection,
+                'decimal_odds' => $odds,
+                'is_active' => 1,
+                'locked_weight' => (int)($current['locked_weight'] ?? 0),
+            ];
+        }
+
+        return $payloads;
+    }
+
+    private function buildWinnerOddsPayloads(array $row, int $index, array &$errors): array
+    {
+        $teamName = trim((string)($row['team_name'] ?? ''));
+        if ($teamName === '') {
+            $errors[] = $this->rowError($index, 'team_name is required');
+
+            return [];
+        }
+
+        $odds = $this->decimalFromRow($row, 'draftkings_decimal');
+        if ($odds <= 1) {
+            $errors[] = $this->rowError($index, 'draftkings_decimal must be greater than 1');
+
+            return [];
+        }
+
+        $current = $this->repository->findOdds('winner', null, $teamName);
+
+        return [[
+            'market' => 'winner',
+            'match_id' => null,
+            'selection' => $teamName,
+            'decimal_odds' => $odds,
+            'is_active' => 1,
+            'locked_weight' => (int)($current['locked_weight'] ?? 0),
+        ]];
+    }
+
+    private function formatOdds(array $row): array
+    {
+        $row['odds_id'] = (int)($row['odds_id'] ?? 0);
+        $row['match_id'] = isset($row['match_id']) ? (int)$row['match_id'] : null;
+        $row['decimal_odds'] = (float)$row['decimal_odds'];
+        $row['previous_odds'] = isset($row['previous_odds']) ? (float)$row['previous_odds'] : null;
+        $row['is_active'] = (int)($row['is_active'] ?? 0);
+        $row['locked_weight'] = (int)($row['locked_weight'] ?? 0);
+        $row['home_team'] = $row['home_team'] ?? null;
+        $row['away_team'] = $row['away_team'] ?? null;
+        $row['team_pair'] = $row['home_team'] && $row['away_team']
+            ? $row['home_team'] . ' vs ' . $row['away_team']
+            : '-';
+        $row['selection_label'] = $this->selectionLabel($row);
+
+        return $row;
+    }
+
+    private function selectionLabel(array $row): string
+    {
+        if (($row['market'] ?? '') !== '1x2') {
+            return (string)($row['selection'] ?? '');
+        }
+
+        if (($row['selection'] ?? '') === 'home') {
+            return ($row['home_team'] ?: '主队') . ' 胜';
+        }
+
+        if (($row['selection'] ?? '') === 'away') {
+            return ($row['away_team'] ?: '客队') . ' 胜';
+        }
+
+        if (($row['selection'] ?? '') === 'draw') {
+            return '平局';
+        }
+
+        return (string)($row['selection'] ?? '');
+    }
+
+    private function fail(string $message): array
+    {
+        return [
+            'success' => false,
+            'message' => $message,
+            'data' => [],
+        ];
+    }
+
+    private function decimalFromRow(array $row, string $column): float
+    {
+        $value = trim((string)($row[$column] ?? ''));
+        if ($value === '') {
+            return 0.0;
+        }
+
+        return (float)str_replace(',', '', $value);
+    }
+
+    private function rowError(int $index, string $message): array
+    {
+        return [
+            'row' => $index + 1,
+            'message' => $message,
+        ];
+    }
+}

+ 244 - 0
app/Services/WorldCup/WorldCupReferralRewardService.php

@@ -0,0 +1,244 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupReferralRepository;
+use App\Services\WorldCup\Repositories\WorldCupReferralRepositoryInterface;
+use Log;
+
+class WorldCupReferralRewardService
+{
+    private $repository;
+
+    private $referralService;
+
+    public function __construct(
+        WorldCupReferralRepositoryInterface $repository = null,
+        WorldCupReferralService $referralService = null
+    ) {
+        $this->repository = $repository ?: new SqlWorldCupReferralRepository();
+        $this->referralService = $referralService ?: new WorldCupReferralService();
+    }
+
+    public function getInviteState(int $userId): array
+    {
+        $state = $this->repository->ensureUserState($userId);
+
+        return [
+            'invite_code' => $state['invite_code'],
+            'referral_bound' => !empty($state['referred_by_user_id']),
+            'referred_by_user_id' => $state['referred_by_user_id'] ?? null,
+        ];
+    }
+
+    public function bindInvite(int $inviteeId, string $inviteCode, string $bindType = 'manual'): array
+    {
+        $inviteCode = trim($inviteCode);
+        if ($inviteCode === '') {
+            return $this->fail('Invite code is required');
+        }
+
+        $this->repository->ensureUserState($inviteeId);
+        $referrer = $this->repository->findUserByInviteCode($inviteCode);
+        if (!$referrer) {
+            return $this->fail('Invite code not found');
+        }
+
+        $referrerId = (int)$referrer['user_id'];
+        if ($referrerId === $inviteeId) {
+            return $this->fail('Cannot invite yourself');
+        }
+
+        $exists = $this->repository->findReferralByInvitee($inviteeId);
+        if ($exists) {
+            return [
+                'success' => true,
+                'status' => 'exists',
+                'data' => $exists,
+            ];
+        }
+
+        $referral = $this->repository->bindReferral($referrerId, $inviteeId, $bindType);
+
+        return [
+            'success' => true,
+            'status' => 'created',
+            'data' => $referral,
+        ];
+    }
+
+    public function handleFirstDeposit(int $userId, float $payAmt, string $orderSn): array
+    {
+        if (!$this->repository->isFirstSuccessfulOrder($userId, $orderSn)) {
+            Log::info('Order is not the first successful deposit', [
+                'user_id' => $userId,
+                'order_sn' => $orderSn,
+            ]);
+            return $this->ok('not_first_deposit');
+        }
+
+        $referral = $this->repository->findReferralByInvitee($userId);
+        if (!$referral) {
+            Log::info('No referral found for user', [
+                'user_id' => $userId,
+                'order_sn' => $orderSn,
+            ]);
+            return $this->ok('not_bound');
+        }
+
+        $exists = $this->repository->findRewardByInvitee($userId);
+        if ($exists) {
+            Log::info('Reward already exists for user', [
+                'user_id' => $userId,
+                'order_sn' => $orderSn,
+                'reward_id' => $exists['reward_id'],
+            ]);
+            return [
+                'success' => true,
+                'status' => 'exists',
+                'data' => $exists,
+            ];
+        }
+
+        $firstDepositAmount = (int)round($payAmt * 100);
+        $calculation = $this->referralService->calculateReward($firstDepositAmount);
+        if (!$calculation['qualifies']) {
+            Log::info('User is not qualified for reward', [
+                'user_id' => $userId,
+                'order_sn' => $orderSn,
+            ]);
+            return $this->ok('not_qualified');
+        }
+
+        $risk = $this->scoreRisk((int)$referral['referrer_id'], $userId);
+        $reward = $this->repository->createReward([
+            'referrer_id' => (int)$referral['referrer_id'],
+            'invitee_id' => $userId,
+            'first_deposit_order_sn' => $orderSn,
+            'first_deposit_amt' => $firstDepositAmount,
+            'reward_each' => $calculation['reward_each'],
+            'total_liability' => $calculation['total_liability'],
+            'risk_score' => $risk['risk_score'],
+            'risk_level' => $risk['risk_level'],
+            'signals' => json_encode($risk['signals']),
+            'status' => 'reviewing',
+        ]);
+
+        return [
+            'success' => true,
+            'status' => 'created',
+            'data' => $reward,
+        ];
+    }
+
+    public function generateMissingRewards(int $limit = 200): array
+    {
+        $orders = $this->repository->paidOrdersMissingRewards($limit);
+        $result = [
+            'checked' => count($orders),
+            'created' => 0,
+            'skipped' => 0,
+        ];
+
+        foreach ($orders as $order) {
+            $handled = $this->handleFirstDeposit(
+                (int)$order['user_id'],
+                ((float)$order['amount']) / 100,
+                (string)$order['order_sn']
+            );
+
+            if (($handled['status'] ?? '') === 'created') {
+                $result['created']++;
+            } else {
+                $result['skipped']++;
+            }
+        }
+
+        return $result;
+    }
+
+    public function inviteLog(int $userId, string $type = 'invited', int $limit = 20): array
+    {
+        $type = $type === 'deposits' ? 'deposits' : 'invited';
+        $limit = $this->normalizeLimit($limit);
+
+        return [
+            'server_time' => time(),
+            'stats' => $this->repository->inviteStats($userId),
+            'type' => $type,
+            'list' => $this->repository->inviteLogs($userId, $type, $limit),
+        ];
+    }
+
+    public function rewardLog(int $userId, int $limit = 20): array
+    {
+        $limit = $this->normalizeLimit($limit);
+
+        return [
+            'stats' => $this->repository->rewardStats($userId),
+            'list' => $this->repository->rewardLogs($userId, $limit),
+        ];
+    }
+
+    private function scoreRisk(int $referrerId, int $inviteeId): array
+    {
+        $referrer = $this->repository->findUserState($referrerId) ?: [];
+        $invitee = $this->repository->findUserState($inviteeId) ?: [];
+        $signals = [];
+        $score = 0;
+
+        if (!empty($referrer['device_fp'])
+            && !empty($invitee['device_fp'])
+            && $referrer['device_fp'] === $invitee['device_fp']) {
+            $signals[] = 'same_device';
+            $score += 40;
+        }
+
+        if (!empty($referrer['pay_account_hash'])
+            && !empty($invitee['pay_account_hash'])
+            && $referrer['pay_account_hash'] === $invitee['pay_account_hash']) {
+            $signals[] = 'same_payment';
+            $score += 40;
+        }
+
+        if (!empty($referrer['signup_ip'])
+            && !empty($invitee['signup_ip'])
+            && $referrer['signup_ip'] === $invitee['signup_ip']) {
+            $signals[] = 'same_ip';
+            $score += 20;
+        }
+
+        return [
+            'risk_score' => $score,
+            'risk_level' => $score >= 40 ? 'high' : ($score >= 20 ? 'medium' : 'low'),
+            'signals' => $signals,
+        ];
+    }
+
+    private function normalizeLimit(int $limit): int
+    {
+        if ($limit <= 0) {
+            return 20;
+        }
+
+        return min($limit, 100);
+    }
+
+    private function ok(string $status): array
+    {
+        return [
+            'success' => true,
+            'status' => $status,
+            'data' => [],
+        ];
+    }
+
+    private function fail(string $message): array
+    {
+        return [
+            'success' => false,
+            'message' => $message,
+            'data' => [],
+        ];
+    }
+}

+ 35 - 0
app/Services/WorldCup/WorldCupReferralService.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+class WorldCupReferralService
+{
+    public const REFERRAL_RATE = 0.5;
+    public const REFERRAL_CAP = 10000;
+    public const QUALIFY_MIN_DEPOSIT = 1000;
+
+    public const INVITE_DESCRIPTION = 'When your friend makes their first deposit, you each get 50% of it (up to $100 each)';
+    public const RISK_WARNING = 'Risk warning: Self-invites (same device/payment) and bulk or bot signups are banned — rewards frozen, permanent ban, clawback.';
+
+    public function calculateReward(int $firstDepositAmount): array
+    {
+        $rewardEach = min(
+            self::REFERRAL_CAP,
+            (int)round($firstDepositAmount * self::REFERRAL_RATE)
+        );
+
+        return [
+            'reward_each' => $rewardEach,
+            'total_liability' => $rewardEach * 2,
+            'qualifies' => $firstDepositAmount >= self::QUALIFY_MIN_DEPOSIT,
+        ];
+    }
+
+    public function getInviteCopy(): array
+    {
+        return [
+            'description' => self::INVITE_DESCRIPTION,
+            'risk_warning' => self::RISK_WARNING,
+        ];
+    }
+}

+ 178 - 0
app/Services/WorldCup/WorldCupReviewService.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupReviewRepository;
+use App\Services\WorldCup\Repositories\WorldCupReviewRepositoryInterface;
+
+class WorldCupReviewService
+{
+    public const REJECT_REASON = 'Same device / payment account · failed risk check';
+
+    private $repository;
+
+    public function __construct(WorldCupReviewRepositoryInterface $repository = null)
+    {
+        $this->repository = $repository ?: new SqlWorldCupReviewRepository();
+    }
+
+    public function approve(int $rewardId, string $actor): array
+    {
+        $reward = $this->repository->findReward($rewardId);
+        if (!$reward) {
+            return $this->fail('Reward not found');
+        }
+
+        if ($reward['status'] !== 'reviewing') {
+            return $this->fail('Only reviewing rewards can be approved');
+        }
+
+        $this->repository->payReward($reward);
+        $this->repository->updateRewardStatus($rewardId, 'approved', $actor);
+        $this->repository->writeAudit($rewardId, $actor, 'approve', null, $reward['status'], 'approved');
+
+        return $this->success(['reward_id' => $rewardId, 'status' => 'approved']);
+    }
+
+    public function reject(int $rewardId, string $actor, string $reasonCode): array
+    {
+        if (trim($reasonCode) === '') {
+            return $this->fail('Reason is required');
+        }
+
+        $reward = $this->repository->findReward($rewardId);
+        if (!$reward) {
+            return $this->fail('Reward not found');
+        }
+
+        if ($reward['status'] !== 'reviewing') {
+            return $this->fail('Only reviewing rewards can be rejected');
+        }
+
+        $this->repository->sendRejectMail($reward, self::REJECT_REASON);
+        $this->repository->updateRewardStatus($rewardId, 'rejected', $actor, self::REJECT_REASON);
+        $this->repository->writeAudit($rewardId, $actor, 'reject', self::REJECT_REASON, $reward['status'], 'rejected');
+
+        return $this->success(['reward_id' => $rewardId, 'status' => 'rejected']);
+    }
+
+    public function hold(int $rewardId, string $actor): array
+    {
+        $reward = $this->repository->findReward($rewardId);
+        if (!$reward) {
+            return $this->fail('Reward not found');
+        }
+
+        if ($reward['status'] !== 'reviewing') {
+            return $this->fail('Only reviewing rewards can be held');
+        }
+
+        $this->repository->updateRewardStatus($rewardId, 'on_hold', $actor);
+        $this->repository->writeAudit($rewardId, $actor, 'hold', null, $reward['status'], 'on_hold');
+
+        return $this->success(['reward_id' => $rewardId, 'status' => 'on_hold']);
+    }
+
+    public function clawback(int $rewardId, string $actor, string $reasonCode, bool $banUsers = false): array
+    {
+        if (trim($reasonCode) === '') {
+            return $this->fail('Reason is required');
+        }
+
+        $reward = $this->repository->findReward($rewardId);
+        if (!$reward) {
+            return $this->fail('Reward not found');
+        }
+
+        if ($reward['status'] !== 'approved') {
+            return $this->fail('Only approved rewards can be clawed back');
+        }
+
+        $this->repository->clawbackReward($reward, $banUsers);
+        $this->repository->updateRewardStatus($rewardId, 'clawed_back', $actor, $reasonCode);
+        $this->repository->writeAudit(
+            $rewardId,
+            $actor,
+            'clawback',
+            $reasonCode,
+            $reward['status'],
+            'clawed_back',
+            ['ban_users' => $banUsers]
+        );
+
+        return $this->success(['reward_id' => $rewardId, 'status' => 'clawed_back']);
+    }
+
+    public function batchApprove(array $rewardIds, string $actor): array
+    {
+        $rewards = $this->repository->findRewards($rewardIds);
+
+        foreach ($rewards as $reward) {
+            if ($reward['status'] !== 'reviewing') {
+                return $this->fail('Only reviewing rewards can be batch approved');
+            }
+
+            if ($reward['risk_level'] === 'high') {
+                return $this->fail('High risk rewards must be reviewed one by one');
+            }
+        }
+
+        foreach ($rewards as $reward) {
+            $this->approve((int)$reward['reward_id'], $actor);
+        }
+
+        return $this->success(['affected' => count($rewards)]);
+    }
+
+    public function batchReject(array $rewardIds, string $actor, string $reasonCode): array
+    {
+        if (trim($reasonCode) === '') {
+            return $this->fail('Reason is required');
+        }
+
+        $rewards = $this->repository->findRewards($rewardIds);
+
+        foreach ($rewards as $reward) {
+            if ($reward['status'] !== 'reviewing') {
+                return $this->fail('Only reviewing rewards can be batch rejected');
+            }
+        }
+
+        foreach ($rewards as $reward) {
+            $this->reject((int)$reward['reward_id'], $actor, $reasonCode);
+        }
+
+        return $this->success(['affected' => count($rewards)]);
+    }
+
+    public function queue(array $filters): array
+    {
+        return $this->repository->queue($filters);
+    }
+
+    public function kpi(): array
+    {
+        return $this->repository->kpi();
+    }
+
+    public function auditLogs($filters = 100): array
+    {
+        return $this->repository->auditLogs($filters);
+    }
+
+    private function success(array $data): array
+    {
+        return [
+            'success' => true,
+            'data' => $data,
+        ];
+    }
+
+    private function fail(string $message): array
+    {
+        return [
+            'success' => false,
+            'message' => $message,
+        ];
+    }
+}

+ 308 - 0
app/Services/WorldCup/WorldCupScheduleUpdateService.php

@@ -0,0 +1,308 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupScheduleRepository;
+use App\Services\WorldCup\Repositories\WorldCupScheduleRepositoryInterface;
+
+class WorldCupScheduleUpdateService
+{
+    private const ALLOWED_STATUSES = ['scheduled', 'closed', 'finished'];
+
+    private $repository;
+
+    public function __construct(WorldCupScheduleRepositoryInterface $repository = null)
+    {
+        $this->repository = $repository ?: new SqlWorldCupScheduleRepository();
+    }
+
+    public function unresolvedMatchesByDate(string $scheduleDate): array
+    {
+        if (!$this->isValidDate($scheduleDate)) {
+            return [];
+        }
+
+        return $this->repository->unresolvedMatchesByDate($scheduleDate);
+    }
+
+    public function allMatches(): array
+    {
+        return $this->repository->allMatches();
+    }
+
+    public function matchesByDate(string $scheduleDate): array
+    {
+        if (!$this->isValidDate($scheduleDate)) {
+            return [];
+        }
+
+        return $this->repository->matchesByDate($scheduleDate);
+    }
+
+    public function updateUnresolvedMatches(string $scheduleDate, array $matches, string $actor): array
+    {
+        if (!$this->isValidDate($scheduleDate)) {
+            return $this->fail('Invalid schedule date', [
+                'updated' => 0,
+                'skipped' => 0,
+                'errors' => [],
+            ]);
+        }
+
+        $updated = 0;
+        $skipped = 0;
+        $errors = [];
+        $applied = [];
+
+        foreach ($matches as $index => $match) {
+            $normalized = $this->normalizeMatch($match, $index);
+            if (!$normalized['success']) {
+                $errors[] = $normalized['error'];
+                continue;
+            }
+
+            $didUpdate = $this->repository->updateMatchByNoAndDate(
+                $normalized['match_no'],
+                $scheduleDate,
+                $normalized['attributes']
+            );
+
+            if ($didUpdate) {
+                $updated++;
+                $applied[] = [
+                    'match_no' => $normalized['match_no'],
+                    'home_team' => $normalized['attributes']['home_team'],
+                    'away_team' => $normalized['attributes']['away_team'],
+                    'status' => $normalized['attributes']['status'],
+                ];
+                continue;
+            }
+
+            $skipped++;
+        }
+
+        if ($updated > 0) {
+            $this->repository->writeScheduleAudit($actor, 'schedule_update', [
+                'schedule_date' => $scheduleDate,
+                'updated' => $updated,
+                'skipped' => $skipped,
+                'errors' => $errors,
+                'matches' => $applied,
+            ]);
+        }
+
+        return [
+            'success' => $updated > 0,
+            'message' => $updated > 0 ? 'success' : 'No unresolved matches updated',
+            'data' => [
+                'updated' => $updated,
+                'skipped' => $skipped,
+                'errors' => $errors,
+            ],
+        ];
+    }
+
+    public function updateMatches(array $matches, string $actor): array
+    {
+        $updated = 0;
+        $skipped = 0;
+        $errors = [];
+        $applied = [];
+
+        foreach ($matches as $index => $match) {
+            if (!$this->shouldUpdateRow($match)) {
+                $skipped++;
+                continue;
+            }
+
+            $normalized = $this->normalizeMatch($match, $index);
+            if (!$normalized['success']) {
+                $errors[] = $normalized['error'];
+                continue;
+            }
+
+            $didUpdate = $this->repository->updateMatchByNo(
+                $normalized['match_no'],
+                $normalized['attributes']
+            );
+
+            if ($didUpdate) {
+                $updated++;
+                $applied[] = [
+                    'match_no' => $normalized['match_no'],
+                    'home_team' => $normalized['attributes']['home_team'],
+                    'away_team' => $normalized['attributes']['away_team'],
+                    'status' => $normalized['attributes']['status'],
+                ];
+                continue;
+            }
+
+            $skipped++;
+        }
+
+        if ($updated > 0) {
+            $this->repository->writeScheduleAudit($actor, 'schedule_update', [
+                'updated' => $updated,
+                'skipped' => $skipped,
+                'errors' => $errors,
+                'matches' => $applied,
+            ]);
+        }
+
+        return [
+            'success' => $updated > 0,
+            'message' => $updated > 0 ? 'success' : 'No matches updated',
+            'data' => [
+                'updated' => $updated,
+                'skipped' => $skipped,
+                'errors' => $errors,
+            ],
+        ];
+    }
+
+    public function importExistingMatches(array $rows, string $actor): array
+    {
+        $updated = 0;
+        $skipped = 0;
+        $errors = [];
+        $applied = [];
+
+        foreach ($rows as $index => $row) {
+            $normalized = $this->normalizeImportedMatch($row, $index);
+            if (!$normalized['success']) {
+                $errors[] = $normalized['error'];
+                continue;
+            }
+
+            $didUpdate = $this->repository->updateMatchById(
+                $normalized['match_id'],
+                $normalized['attributes']
+            );
+
+            if ($didUpdate) {
+                $updated++;
+                $applied[] = [
+                    'match_id' => $normalized['match_id'],
+                    'home_team' => $normalized['attributes']['home_team'],
+                    'away_team' => $normalized['attributes']['away_team'],
+                ];
+                continue;
+            }
+
+            $skipped++;
+        }
+
+        if ($updated > 0) {
+            $this->repository->writeScheduleAudit($actor, 'schedule_import', [
+                'updated' => $updated,
+                'skipped' => $skipped,
+                'errors' => $errors,
+                'matches' => $applied,
+            ]);
+        }
+
+        return [
+            'success' => $updated > 0,
+            'message' => $updated > 0 ? 'success' : 'No matches imported',
+            'data' => [
+                'updated' => $updated,
+                'skipped' => $skipped,
+                'errors' => $errors,
+            ],
+        ];
+    }
+
+    private function normalizeMatch(array $match, int $index): array
+    {
+        $matchNo = (int)($match['match_no'] ?? 0);
+        $homeTeam = trim((string)($match['home_team'] ?? ''));
+        $awayTeam = trim((string)($match['away_team'] ?? ''));
+        $status = trim((string)($match['status'] ?? 'closed'));
+
+        if ($matchNo <= 0) {
+            return $this->rowError($index, 'match_no is required');
+        }
+
+        if ($homeTeam === '' || $awayTeam === '') {
+            return $this->rowError($index, 'home_team and away_team are required');
+        }
+
+        if (!in_array($status, self::ALLOWED_STATUSES, true)) {
+            return $this->rowError($index, 'status is invalid');
+        }
+
+        $attributes = [
+            'home_team' => $homeTeam,
+            'away_team' => $awayTeam,
+            'status' => $status,
+        ];
+
+        if (isset($match['venue']) && trim((string)$match['venue']) !== '') {
+            $attributes['venue'] = trim((string)$match['venue']);
+        }
+
+        if (isset($match['kickoff_at']) && trim((string)$match['kickoff_at']) !== '') {
+            $attributes['kickoff_at'] = trim((string)$match['kickoff_at']);
+        }
+
+        return [
+            'success' => true,
+            'match_no' => $matchNo,
+            'attributes' => $attributes,
+        ];
+    }
+
+    private function normalizeImportedMatch(array $match, int $index): array
+    {
+        $matchId = (int)($match['match_id'] ?? 0);
+        $homeTeam = trim((string)($match['home_name'] ?? ''));
+        $awayTeam = trim((string)($match['away_name'] ?? ''));
+
+        if ($matchId <= 0) {
+            return $this->rowError($index, 'match_id is required');
+        }
+
+        if ($homeTeam === '' || $awayTeam === '') {
+            return $this->rowError($index, 'home_name and away_name are required');
+        }
+
+        return [
+            'success' => true,
+            'match_id' => $matchId,
+            'attributes' => [
+                'home_team' => $homeTeam,
+                'away_team' => $awayTeam,
+            ],
+        ];
+    }
+
+    private function rowError(int $index, string $message): array
+    {
+        return [
+            'success' => false,
+            'error' => [
+                'row' => $index + 1,
+                'message' => $message,
+            ],
+        ];
+    }
+
+    private function shouldUpdateRow(array $match): bool
+    {
+        return (string)($match['enabled'] ?? '') === '1';
+    }
+
+    private function isValidDate(string $date): bool
+    {
+        return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1;
+    }
+
+    private function fail(string $message, array $data): array
+    {
+        return [
+            'success' => false,
+            'message' => $message,
+            'data' => $data,
+        ];
+    }
+}

+ 105 - 0
app/Services/WorldCup/WorldCupSettlementService.php

@@ -0,0 +1,105 @@
+<?php
+
+namespace App\Services\WorldCup;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupSettlementRepository;
+use App\Services\WorldCup\Repositories\WorldCupSettlementRepositoryInterface;
+
+class WorldCupSettlementService
+{
+    private $repository;
+
+    public function __construct(WorldCupSettlementRepositoryInterface $repository = null)
+    {
+        $this->repository = $repository ?: new SqlWorldCupSettlementRepository();
+    }
+
+    public function settleMatch(int $matchId, string $result, string $actor): array
+    {
+        if (!in_array($result, ['home', 'draw', 'away'], true)) {
+            return $this->fail('Invalid match result');
+        }
+
+        $match = $this->repository->findMatch($matchId);
+        if (!$match) {
+            return $this->fail('Match not found');
+        }
+
+        if (($match['status'] ?? '') === 'finished') {
+            return $this->fail('Match already settled');
+        }
+
+        if ($result === 'draw' && ($match['stage'] ?? '') !== 'group') {
+            return $this->fail('Draw is only available for group stage');
+        }
+
+        $bets = $this->repository->pendingBetsForMatch($matchId);
+        $summary = $this->settleBets($bets, $result);
+        $this->repository->markMatchFinished($matchId, $result);
+        $this->repository->writeAudit($actor, 'settle_match', [
+            'match_id' => $matchId,
+            'result' => $result,
+            'summary' => $summary,
+        ]);
+
+        return $this->success($summary);
+    }
+
+    public function settleWinner(string $selection, string $actor): array
+    {
+        $selection = trim($selection);
+        if ($selection === '') {
+            return $this->fail('Winner selection is required');
+        }
+
+        $summary = $this->settleBets($this->repository->pendingWinnerBets(), $selection);
+        $this->repository->writeAudit($actor, 'settle_winner', [
+            'selection' => $selection,
+            'summary' => $summary,
+        ]);
+
+        return $this->success($summary);
+    }
+
+    private function settleBets(array $bets, string $winningSelection): array
+    {
+        $summary = [
+            'won_count' => 0,
+            'lost_count' => 0,
+            'paid_amount' => 0,
+        ];
+
+        foreach ($bets as $bet) {
+            $status = (string)$bet['selection'] === $winningSelection ? 'won' : 'lost';
+            if ($status === 'won') {
+                $paidAmount = $this->repository->payBet($bet);
+                $summary['won_count']++;
+                $summary['paid_amount'] += $paidAmount;
+            } else {
+                $summary['lost_count']++;
+            }
+
+            $this->repository->markBetSettled((int)$bet['bet_id'], $status);
+        }
+
+        return $summary;
+    }
+
+    private function success(array $data): array
+    {
+        return [
+            'success' => true,
+            'message' => '',
+            'data' => $data,
+        ];
+    }
+
+    private function fail(string $message): array
+    {
+        return [
+            'success' => false,
+            'message' => $message,
+            'data' => [],
+        ];
+    }
+}

+ 820 - 0
database/world_cup_activity.sql

@@ -0,0 +1,820 @@
+-- World Cup activity tables for SQL Server.
+-- Amount fields use integer cents (NumConfig::NUM_VALUE = 100).
+
+IF OBJECT_ID('agent.dbo.world_cup_user_state', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- user_id: QPAccountsDB.dbo.AccountsInfo.UserID,活动用户主键。
+    -- first_bet_used: 是否已使用世界杯首注奖励,1=已使用,0=未使用。
+    -- invite_code: 用户自己的邀请码,前端展示并用于手动补绑。
+    -- referred_by_user_id: 邀请人的 UserID,未绑定时为空。
+    -- referral_bind_at: 邀请关系绑定时间。
+    -- referral_bind_type: 绑定方式,link=分享链接自动绑定,manual=24h 内手动输入邀请码。
+    -- device_fp: 设备指纹,用于风控识别同设备自邀。
+    -- pay_account_hash: 支付账号哈希,用于风控识别同支付账号。
+    -- signup_ip: 注册 IP,用于风控识别同 IP/同段聚集。
+    -- created_at: 记录创建时间。
+    -- updated_at: 记录更新时间。
+    CREATE TABLE agent.dbo.world_cup_user_state (
+        user_id INT NOT NULL PRIMARY KEY,
+        first_bet_used BIT NOT NULL DEFAULT 0,
+        invite_code VARCHAR(32) NULL,
+        referred_by_user_id INT NULL,
+        referral_bind_at DATETIME NULL,
+        referral_bind_type VARCHAR(16) NULL,
+        device_fp VARCHAR(128) NULL,
+        pay_account_hash VARCHAR(128) NULL,
+        signup_ip VARCHAR(64) NULL,
+        created_at DATETIME NOT NULL DEFAULT GETDATE(),
+        updated_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE UNIQUE INDEX idx_world_cup_user_state_invite_code
+        ON agent.dbo.world_cup_user_state(invite_code)
+        WHERE invite_code IS NOT NULL;
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_matches', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- match_id: 比赛 ID,单场 1X2 盘口主键。
+    -- match_no: FIFA 官方赛程 Match 编号。
+    -- competition: 赛事名称,例如 World Cup。
+    -- stage: 赛事阶段,group=小组赛,round_32=32 强,round_16=16 强,quarter_final=1/4 决赛,semi_final=半决赛,final=决赛。
+    -- group_name: 小组名称,小组赛为 A-L,淘汰赛可为空。
+    -- home_team: 主队名称。
+    -- away_team: 客队名称。
+    -- venue: 比赛场馆名称。
+    -- kickoff_at: 开赛时间,UTC 时间戳;开赛前 1 小时停止投注。
+    -- status: 比赛状态,scheduled=可维护/待开赛,closed=关闭投注,finished=已完赛。
+    -- result: 比赛赛果,home/draw/away;未结算时为空。
+    -- created_at: 记录创建时间。
+    -- updated_at: 记录更新时间。
+    CREATE TABLE agent.dbo.world_cup_matches (
+        match_id INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
+        match_no INT NULL,
+        competition VARCHAR(64) NOT NULL,
+        stage VARCHAR(32) NOT NULL DEFAULT 'group',
+        group_name VARCHAR(8) NULL,
+        home_team VARCHAR(64) NOT NULL,
+        away_team VARCHAR(64) NOT NULL,
+        venue VARCHAR(128) NULL,
+        kickoff_at DATETIME NOT NULL,
+        status VARCHAR(16) NOT NULL DEFAULT 'scheduled',
+        result VARCHAR(16) NULL,
+        created_at DATETIME NOT NULL DEFAULT GETDATE(),
+        updated_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE INDEX idx_world_cup_matches_open
+        ON agent.dbo.world_cup_matches(status, kickoff_at);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_matches', 'U') IS NOT NULL
+BEGIN
+    IF COL_LENGTH('agent.dbo.world_cup_matches', 'match_no') IS NULL
+        ALTER TABLE agent.dbo.world_cup_matches ADD match_no INT NULL;
+
+    IF COL_LENGTH('agent.dbo.world_cup_matches', 'stage') IS NULL
+        ALTER TABLE agent.dbo.world_cup_matches ADD stage VARCHAR(32) NOT NULL DEFAULT 'group';
+
+    IF COL_LENGTH('agent.dbo.world_cup_matches', 'group_name') IS NULL
+        ALTER TABLE agent.dbo.world_cup_matches ADD group_name VARCHAR(8) NULL;
+
+    IF COL_LENGTH('agent.dbo.world_cup_matches', 'venue') IS NULL
+        ALTER TABLE agent.dbo.world_cup_matches ADD venue VARCHAR(128) NULL;
+END;
+GO
+
+IF OBJECT_ID('agent.dbo.world_cup_odds', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- odds_id: 赔率记录 ID。
+    -- match_id: 比赛 ID;Winner 夺冠盘为空,1X2 单场盘必填。
+    -- market: 盘口类型,winner=夺冠盘,1x2=单场胜平负。
+    -- selection: 投注选项;Winner 为球队名,1X2 为 home/draw/away。
+    -- decimal_odds: 当前十进制赔率。
+    -- previous_odds: 上一次赔率,用于前端展示涨跌箭头。
+    -- is_active: 是否展示/可投注,1=启用,0=隐藏。
+    -- locked_weight: 后台排序权重,数值越大越靠前。
+    -- updated_at: 赔率更新时间。
+    CREATE TABLE agent.dbo.world_cup_odds (
+        odds_id INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
+        match_id INT NULL,
+        market VARCHAR(16) NOT NULL,
+        selection VARCHAR(64) NOT NULL,
+        decimal_odds DECIMAL(10, 2) NOT NULL,
+        previous_odds DECIMAL(10, 2) NULL,
+        is_active BIT NOT NULL DEFAULT 1,
+        locked_weight INT NOT NULL DEFAULT 0,
+        updated_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE INDEX idx_world_cup_odds_market
+        ON agent.dbo.world_cup_odds(market, match_id, is_active);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_match_favorites', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- id: 收藏记录 ID。
+    -- user_id: 收藏用户 UserID。
+    -- match_id: 被收藏的单场比赛 ID,仅支持 1X2 比赛,不支持 Winner 盘。
+    -- created_at: 收藏创建时间。
+    CREATE TABLE agent.dbo.world_cup_match_favorites (
+        id INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
+        user_id INT NOT NULL,
+        match_id INT NOT NULL,
+        created_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE UNIQUE INDEX idx_world_cup_match_favorites_user_match
+        ON agent.dbo.world_cup_match_favorites(user_id, match_id);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_bets', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- bet_id: 世界杯注单 ID。
+    -- user_id: 下注用户 UserID。
+    -- game_id: 前端展示的 8 位 GameID,冗余保存便于查询。
+    -- idempotency_key: 客户端幂等键,同一用户同一 key 不重复下单。
+    -- market: 盘口类型,winner=夺冠盘,1x2=单场胜平负。
+    -- match_id: 比赛 ID;Winner 夺冠盘为空。
+    -- selection: 投注选项,球队名或 home/draw/away。
+    -- stake: 下注额,整数分。
+    -- odds: 下单时锁定的十进制赔率。
+    -- is_first_bet: 是否为用户世界杯首注,1=首注。
+    -- bonus_amount: 首注 +50% 额外奖励金额,整数分。
+    -- potential_payout: 潜在派彩金额,整数分,stake * odds + bonus_amount。
+    -- status: 注单状态,pending=待结算,won=赢,lost=输。
+    -- settled_at: 结算时间。
+    -- created_at: 下单时间。
+    -- updated_at: 注单更新时间。
+    CREATE TABLE agent.dbo.world_cup_bets (
+        bet_id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
+        user_id INT NOT NULL,
+        game_id INT NOT NULL,
+        idempotency_key VARCHAR(64) NOT NULL,
+        market VARCHAR(16) NOT NULL,
+        match_id INT NULL,
+        selection VARCHAR(64) NOT NULL,
+        stake INT NOT NULL,
+        odds DECIMAL(10, 2) NOT NULL,
+        is_first_bet BIT NOT NULL DEFAULT 0,
+        bonus_amount INT NOT NULL DEFAULT 0,
+        potential_payout INT NOT NULL DEFAULT 0,
+        status VARCHAR(16) NOT NULL DEFAULT 'pending',
+        settled_at DATETIME NULL,
+        created_at DATETIME NOT NULL DEFAULT GETDATE(),
+        updated_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE UNIQUE INDEX idx_world_cup_bets_user_idempotency
+        ON agent.dbo.world_cup_bets(user_id, idempotency_key);
+
+    CREATE INDEX idx_world_cup_bets_match_status
+        ON agent.dbo.world_cup_bets(match_id, status);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_referrals', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- id: 邀请绑定记录 ID。
+    -- referrer_id: 邀请人 UserID。
+    -- invitee_id: 被邀请人 UserID;唯一,一个被邀请人只能绑定一次。
+    -- bind_type: 绑定方式,link=分享链接自动绑定,manual=手动补填邀请码。
+    -- bind_at: 绑定发生时间。
+    -- created_at: 记录创建时间。
+    CREATE TABLE agent.dbo.world_cup_referrals (
+        id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
+        referrer_id INT NOT NULL,
+        invitee_id INT NOT NULL,
+        bind_type VARCHAR(16) NOT NULL,
+        bind_at DATETIME NOT NULL DEFAULT GETDATE(),
+        created_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE UNIQUE INDEX idx_world_cup_referrals_invitee
+        ON agent.dbo.world_cup_referrals(invitee_id);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_referral_rewards', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- reward_id: 邀请奖励单 ID,后台审核对象。
+    -- referrer_id: 邀请人 UserID。
+    -- invitee_id: 被邀请人 UserID;唯一,同一被邀请人首充只生成一次奖励单。
+    -- first_deposit_order_sn: 触发奖励的首充订单号,用于事件监听幂等。
+    -- first_deposit_amt: 触发奖励时的首充实际入账金额,整数分。
+    -- reward_each: 双方各得奖励金额,整数分;规则为 min(first_deposit_amt * 50%, 10000)。
+    -- total_liability: 本奖励单总赔付,整数分,reward_each * 2,最高 20000。
+    -- risk_score: 风控评分,0-100。
+    -- risk_level: 风险等级,low/medium/high。
+    -- signals: 命中的风控信号 JSON 文本。
+    -- status: 审核状态,reviewing/approved/rejected/on_hold/clawed_back。
+    -- reason_code: 驳回或追回原因代码。
+    -- review_by: 审核员标识。
+    -- reviewed_at: 审核时间。
+    -- submitted_at: 奖励单提交审核时间,用于计算 24h SLA。
+    -- created_at: 记录创建时间。
+    -- updated_at: 记录更新时间。
+    CREATE TABLE agent.dbo.world_cup_referral_rewards (
+        reward_id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
+        referrer_id INT NOT NULL,
+        invitee_id INT NOT NULL,
+        first_deposit_order_sn VARCHAR(64) NULL,
+        first_deposit_amt INT NOT NULL,
+        reward_each INT NOT NULL,
+        total_liability INT NOT NULL,
+        risk_score INT NOT NULL DEFAULT 0,
+        risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
+        signals NVARCHAR(MAX) NULL,
+        status VARCHAR(16) NOT NULL DEFAULT 'reviewing',
+        reason_code VARCHAR(32) NULL,
+        review_by VARCHAR(64) NULL,
+        reviewed_at DATETIME NULL,
+        submitted_at DATETIME NOT NULL DEFAULT GETDATE(),
+        created_at DATETIME NOT NULL DEFAULT GETDATE(),
+        updated_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE UNIQUE INDEX idx_world_cup_referral_rewards_invitee
+        ON agent.dbo.world_cup_referral_rewards(invitee_id);
+
+    CREATE INDEX idx_world_cup_referral_rewards_queue
+        ON agent.dbo.world_cup_referral_rewards(status, risk_level, submitted_at);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_referral_rewards', 'U') IS NOT NULL
+BEGIN
+    IF COL_LENGTH('agent.dbo.world_cup_referral_rewards', 'first_deposit_order_sn') IS NULL
+        ALTER TABLE agent.dbo.world_cup_referral_rewards ADD first_deposit_order_sn VARCHAR(64) NULL;
+END;
+GO
+
+IF OBJECT_ID('agent.dbo.world_cup_referral_rewards', 'U') IS NOT NULL
+    AND NOT EXISTS (
+        SELECT 1
+        FROM sys.indexes
+        WHERE name = 'idx_world_cup_referral_rewards_order_sn'
+            AND object_id = OBJECT_ID('agent.dbo.world_cup_referral_rewards')
+    )
+BEGIN
+    CREATE UNIQUE INDEX idx_world_cup_referral_rewards_order_sn
+        ON agent.dbo.world_cup_referral_rewards(first_deposit_order_sn)
+        WHERE first_deposit_order_sn IS NOT NULL;
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_odds', 'U') IS NOT NULL
+    AND NOT EXISTS (
+        SELECT 1
+        FROM sys.indexes
+        WHERE name = 'idx_world_cup_odds_unique_market'
+            AND object_id = OBJECT_ID('agent.dbo.world_cup_odds')
+    )
+BEGIN
+    CREATE UNIQUE INDEX idx_world_cup_odds_unique_market
+        ON agent.dbo.world_cup_odds(market, match_id, selection);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_risk_signals', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- code: 风控信号代码。
+    -- label: 后台展示名称。
+    -- weight: 风控权重,命中后累加到 risk_score。
+    -- is_active: 是否启用,1=启用,0=停用。
+    -- updated_at: 配置更新时间。
+    CREATE TABLE agent.dbo.world_cup_risk_signals (
+        code VARCHAR(32) NOT NULL PRIMARY KEY,
+        label VARCHAR(128) NOT NULL,
+        weight INT NOT NULL,
+        is_active BIT NOT NULL DEFAULT 1,
+        updated_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    INSERT INTO agent.dbo.world_cup_risk_signals (code, label, weight)
+    VALUES
+        ('same_device', 'Same device', 40),
+        ('same_payment', 'Same payment account', 40),
+        ('same_ip', 'Same IP/subnet', 20),
+        ('reg_velocity', 'Registration velocity', 25),
+        ('fast_deposit', 'Fast first deposit', 15),
+        ('deposit_reversed', 'Deposit reversed', 50),
+        ('multi_account_ring', 'Multi-account ring', 40),
+        ('threshold_clustering', 'Threshold clustering', 20);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_audit_log', 'U') IS NULL
+BEGIN
+    -- 字段说明:
+    -- id: 审计日志 ID。
+    -- reward_id: 关联的邀请奖励单 ID,可为空。
+    -- actor: 操作人,后台审核员或系统任务。
+    -- action: 操作动作,例如 approve/reject/hold/clawback/auto_score。
+    -- reason_code: 操作原因代码。
+    -- before_status: 操作前状态。
+    -- after_status: 操作后状态。
+    -- payload: 操作上下文 JSON 文本。
+    -- created_at: 日志创建时间。
+    CREATE TABLE agent.dbo.world_cup_audit_log (
+        id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
+        reward_id BIGINT NULL,
+        actor VARCHAR(64) NOT NULL,
+        action VARCHAR(32) NOT NULL,
+        reason_code VARCHAR(32) NULL,
+        before_status VARCHAR(16) NULL,
+        after_status VARCHAR(16) NULL,
+        payload NVARCHAR(MAX) NULL,
+        created_at DATETIME NOT NULL DEFAULT GETDATE()
+    );
+
+    CREATE INDEX idx_world_cup_audit_log_reward
+        ON agent.dbo.world_cup_audit_log(reward_id, created_at);
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_matches', 'U') IS NOT NULL
+BEGIN
+    SET IDENTITY_INSERT agent.dbo.world_cup_matches ON;
+
+    MERGE agent.dbo.world_cup_matches AS target
+    USING (VALUES
+        (1, 1, 'World Cup', 'group', 'A', 'Mexico', 'South Africa', 'Mexico City', '2026-06-11 19:00:00', 'scheduled'),
+        (2, 2, 'World Cup', 'group', 'A', 'South Korea', 'Czechia', 'Guadalajara', '2026-06-12 02:00:00', 'scheduled'),
+        (3, 3, 'World Cup', 'group', 'B', 'Canada', 'Bosnia & Herzegovina', 'Toronto', '2026-06-12 19:00:00', 'scheduled'),
+        (4, 4, 'World Cup', 'group', 'B', 'Qatar', 'Switzerland', 'Santa Clara', '2026-06-13 19:00:00', 'scheduled'),
+        (5, 5, 'World Cup', 'group', 'C', 'Brazil', 'Morocco', 'East Rutherford', '2026-06-13 22:00:00', 'scheduled'),
+        (6, 6, 'World Cup', 'group', 'C', 'Haiti', 'Scotland', 'Foxborough', '2026-06-14 01:00:00', 'scheduled'),
+        (7, 7, 'World Cup', 'group', 'D', 'United States', 'Paraguay', 'Inglewood', '2026-06-13 01:00:00', 'scheduled'),
+        (8, 8, 'World Cup', 'group', 'D', 'Australia', 'Turkiye', 'Vancouver', '2026-06-14 16:00:00', 'scheduled'),
+        (9, 9, 'World Cup', 'group', 'E', 'Germany', 'Curacao', 'Houston', '2026-06-14 17:00:00', 'scheduled'),
+        (10, 10, 'World Cup', 'group', 'E', 'Ivory Coast', 'Ecuador', 'Philadelphia', '2026-06-14 23:00:00', 'scheduled'),
+        (11, 11, 'World Cup', 'group', 'F', 'Netherlands', 'Japan', 'Arlington', '2026-06-14 20:00:00', 'scheduled'),
+        (12, 12, 'World Cup', 'group', 'F', 'Sweden', 'Tunisia', 'Monterrey', '2026-06-15 02:00:00', 'scheduled'),
+        (13, 13, 'World Cup', 'group', 'G', 'Belgium', 'Egypt', 'Seattle', '2026-06-15 19:00:00', 'scheduled'),
+        (14, 14, 'World Cup', 'group', 'G', 'Iran', 'New Zealand', 'Inglewood', '2026-06-16 01:00:00', 'scheduled'),
+        (15, 15, 'World Cup', 'group', 'H', 'Spain', 'Cape Verde', 'Atlanta', '2026-06-15 16:00:00', 'scheduled'),
+        (16, 16, 'World Cup', 'group', 'H', 'Saudi Arabia', 'Uruguay', 'Miami Gardens', '2026-06-15 22:00:00', 'scheduled'),
+        (17, 17, 'World Cup', 'group', 'I', 'France', 'Senegal', 'East Rutherford', '2026-06-16 19:00:00', 'scheduled'),
+        (18, 18, 'World Cup', 'group', 'I', 'Iraq', 'Norway', 'Foxborough', '2026-06-16 22:00:00', 'scheduled'),
+        (19, 19, 'World Cup', 'group', 'J', 'Argentina', 'Algeria', 'Kansas City', '2026-06-17 01:00:00', 'scheduled'),
+        (20, 20, 'World Cup', 'group', 'J', 'Austria', 'Jordan', 'Santa Clara', '2026-06-17 04:00:00', 'scheduled'),
+        (21, 21, 'World Cup', 'group', 'K', 'Portugal', 'DR Congo', 'Houston', '2026-06-17 17:00:00', 'scheduled'),
+        (22, 22, 'World Cup', 'group', 'K', 'Uzbekistan', 'Colombia', 'Mexico City', '2026-06-18 02:00:00', 'scheduled'),
+        (23, 23, 'World Cup', 'group', 'L', 'England', 'Croatia', 'Arlington', '2026-06-17 20:00:00', 'scheduled'),
+        (24, 24, 'World Cup', 'group', 'L', 'Ghana', 'Panama', 'Toronto', '2026-06-17 23:00:00', 'scheduled'),
+        (25, 25, 'World Cup', 'group', 'A', 'Czechia', 'South Africa', 'Atlanta', '2026-06-18 16:00:00', 'scheduled'),
+        (26, 26, 'World Cup', 'group', 'A', 'Mexico', 'South Korea', 'Guadalajara', '2026-06-19 01:00:00', 'scheduled'),
+        (27, 27, 'World Cup', 'group', 'B', 'Switzerland', 'Bosnia & Herzegovina', 'Inglewood', '2026-06-18 19:00:00', 'scheduled'),
+        (28, 28, 'World Cup', 'group', 'B', 'Canada', 'Qatar', 'Vancouver', '2026-06-18 22:00:00', 'scheduled'),
+        (29, 29, 'World Cup', 'group', 'C', 'Scotland', 'Morocco', 'Foxborough', '2026-06-19 22:00:00', 'scheduled'),
+        (30, 30, 'World Cup', 'group', 'C', 'Brazil', 'Haiti', 'Philadelphia', '2026-06-20 00:30:00', 'scheduled'),
+        (31, 31, 'World Cup', 'group', 'D', 'United States', 'Australia', 'Seattle', '2026-06-19 19:00:00', 'scheduled'),
+        (32, 32, 'World Cup', 'group', 'D', 'Turkiye', 'Paraguay', 'Santa Clara', '2026-06-20 03:00:00', 'scheduled'),
+        (33, 33, 'World Cup', 'group', 'E', 'Germany', 'Ivory Coast', 'Toronto', '2026-06-20 20:00:00', 'scheduled'),
+        (34, 34, 'World Cup', 'group', 'E', 'Ecuador', 'Curacao', 'Kansas City', '2026-06-21 00:00:00', 'scheduled'),
+        (35, 35, 'World Cup', 'group', 'F', 'Netherlands', 'Sweden', 'Houston', '2026-06-20 17:00:00', 'scheduled'),
+        (36, 36, 'World Cup', 'group', 'F', 'Tunisia', 'Japan', 'Monterrey', '2026-06-21 04:00:00', 'scheduled'),
+        (37, 37, 'World Cup', 'group', 'G', 'Belgium', 'Iran', 'Inglewood', '2026-06-21 19:00:00', 'scheduled'),
+        (38, 38, 'World Cup', 'group', 'G', 'New Zealand', 'Egypt', 'Vancouver', '2026-06-22 01:00:00', 'scheduled'),
+        (39, 39, 'World Cup', 'group', 'H', 'Spain', 'Saudi Arabia', 'Atlanta', '2026-06-21 16:00:00', 'scheduled'),
+        (40, 40, 'World Cup', 'group', 'H', 'Uruguay', 'Cape Verde', 'Miami Gardens', '2026-06-21 22:00:00', 'scheduled'),
+        (41, 41, 'World Cup', 'group', 'I', 'France', 'Iraq', 'Philadelphia', '2026-06-22 21:00:00', 'scheduled'),
+        (42, 42, 'World Cup', 'group', 'I', 'Norway', 'Senegal', 'East Rutherford', '2026-06-23 00:00:00', 'scheduled'),
+        (43, 43, 'World Cup', 'group', 'J', 'Argentina', 'Austria', 'Arlington', '2026-06-22 17:00:00', 'scheduled'),
+        (44, 44, 'World Cup', 'group', 'J', 'Jordan', 'Algeria', 'Santa Clara', '2026-06-23 03:00:00', 'scheduled'),
+        (45, 45, 'World Cup', 'group', 'K', 'Portugal', 'Uzbekistan', 'Houston', '2026-06-23 17:00:00', 'scheduled'),
+        (46, 46, 'World Cup', 'group', 'K', 'Colombia', 'DR Congo', 'Guadalajara', '2026-06-24 02:00:00', 'scheduled'),
+        (47, 47, 'World Cup', 'group', 'L', 'England', 'Ghana', 'Foxborough', '2026-06-23 20:00:00', 'scheduled'),
+        (48, 48, 'World Cup', 'group', 'L', 'Panama', 'Croatia', 'Toronto', '2026-06-23 23:00:00', 'scheduled'),
+        (49, 49, 'World Cup', 'group', 'A', 'Czechia', 'Mexico', 'Mexico City', '2026-06-25 01:00:00', 'scheduled'),
+        (50, 50, 'World Cup', 'group', 'A', 'South Africa', 'South Korea', 'Monterrey', '2026-06-25 01:00:00', 'scheduled'),
+        (51, 51, 'World Cup', 'group', 'B', 'Switzerland', 'Canada', 'Vancouver', '2026-06-24 19:00:00', 'scheduled'),
+        (52, 52, 'World Cup', 'group', 'B', 'Bosnia & Herzegovina', 'Qatar', 'Seattle', '2026-06-24 19:00:00', 'scheduled'),
+        (53, 53, 'World Cup', 'group', 'C', 'Scotland', 'Brazil', 'Miami Gardens', '2026-06-24 22:00:00', 'scheduled'),
+        (54, 54, 'World Cup', 'group', 'C', 'Morocco', 'Haiti', 'Atlanta', '2026-06-24 22:00:00', 'scheduled'),
+        (55, 55, 'World Cup', 'group', 'D', 'Turkiye', 'United States', 'Inglewood', '2026-06-26 02:00:00', 'scheduled'),
+        (56, 56, 'World Cup', 'group', 'D', 'Paraguay', 'Australia', 'Santa Clara', '2026-06-26 02:00:00', 'scheduled'),
+        (57, 57, 'World Cup', 'group', 'E', 'Curacao', 'Ivory Coast', 'Philadelphia', '2026-06-25 20:00:00', 'scheduled'),
+        (58, 58, 'World Cup', 'group', 'E', 'Ecuador', 'Germany', 'East Rutherford', '2026-06-25 20:00:00', 'scheduled'),
+        (59, 59, 'World Cup', 'group', 'F', 'Japan', 'Sweden', 'Arlington', '2026-06-25 23:00:00', 'scheduled'),
+        (60, 60, 'World Cup', 'group', 'F', 'Tunisia', 'Netherlands', 'Kansas City', '2026-06-25 23:00:00', 'scheduled'),
+        (61, 61, 'World Cup', 'group', 'G', 'Egypt', 'Iran', 'Seattle', '2026-06-27 03:00:00', 'scheduled'),
+        (62, 62, 'World Cup', 'group', 'G', 'New Zealand', 'Belgium', 'Vancouver', '2026-06-27 03:00:00', 'scheduled'),
+        (63, 63, 'World Cup', 'group', 'H', 'Cape Verde', 'Saudi Arabia', 'Houston', '2026-06-27 00:00:00', 'scheduled'),
+        (64, 64, 'World Cup', 'group', 'H', 'Uruguay', 'Spain', 'Guadalajara', '2026-06-27 00:00:00', 'scheduled'),
+        (65, 65, 'World Cup', 'group', 'I', 'Norway', 'France', 'Foxborough', '2026-06-26 19:00:00', 'scheduled'),
+        (66, 66, 'World Cup', 'group', 'I', 'Senegal', 'Iraq', 'Toronto', '2026-06-26 19:00:00', 'scheduled'),
+        (67, 67, 'World Cup', 'group', 'J', 'Algeria', 'Austria', 'Kansas City', '2026-06-28 02:00:00', 'scheduled'),
+        (68, 68, 'World Cup', 'group', 'J', 'Jordan', 'Argentina', 'Arlington', '2026-06-28 02:00:00', 'scheduled'),
+        (69, 69, 'World Cup', 'group', 'K', 'Colombia', 'Portugal', 'Miami Gardens', '2026-06-27 23:30:00', 'scheduled'),
+        (70, 70, 'World Cup', 'group', 'K', 'DR Congo', 'Uzbekistan', 'Atlanta', '2026-06-27 23:30:00', 'scheduled'),
+        (71, 71, 'World Cup', 'group', 'L', 'Panama', 'England', 'East Rutherford', '2026-06-27 21:00:00', 'scheduled'),
+        (72, 72, 'World Cup', 'group', 'L', 'Croatia', 'Ghana', 'Philadelphia', '2026-06-27 21:00:00', 'scheduled'),
+        (73, 73, 'World Cup', 'round_32', NULL, 'Winner 73 A', 'Winner 73 B', 'Inglewood', '2026-06-28 19:00:00', 'closed'),
+        (74, 74, 'World Cup', 'round_32', NULL, 'Winner 74 A', 'Winner 74 B', 'Houston', '2026-06-29 17:00:00', 'closed'),
+        (75, 75, 'World Cup', 'round_32', NULL, 'Winner 75 A', 'Winner 75 B', 'Foxborough', '2026-06-29 20:30:00', 'closed'),
+        (76, 76, 'World Cup', 'round_32', NULL, 'Winner 76 A', 'Winner 76 B', 'Monterrey', '2026-06-30 01:00:00', 'closed'),
+        (77, 77, 'World Cup', 'round_32', NULL, 'Winner 77 A', 'Winner 77 B', 'Arlington', '2026-06-30 17:00:00', 'closed'),
+        (78, 78, 'World Cup', 'round_32', NULL, 'Winner 78 A', 'Winner 78 B', 'East Rutherford', '2026-06-30 21:00:00', 'closed'),
+        (79, 79, 'World Cup', 'round_32', NULL, 'Winner 79 A', 'Winner 79 B', 'Mexico City', '2026-07-01 01:00:00', 'closed'),
+        (80, 80, 'World Cup', 'round_32', NULL, 'Winner 80 A', 'Winner 80 B', 'Atlanta', '2026-07-01 16:00:00', 'closed'),
+        (81, 81, 'World Cup', 'round_32', NULL, 'Winner 81 A', 'Winner 81 B', 'Seattle', '2026-07-01 20:00:00', 'closed'),
+        (82, 82, 'World Cup', 'round_32', NULL, 'Winner 82 A', 'Winner 82 B', 'Santa Clara', '2026-07-02 00:00:00', 'closed'),
+        (83, 83, 'World Cup', 'round_32', NULL, 'Winner 83 A', 'Winner 83 B', 'Inglewood', '2026-07-02 19:00:00', 'closed'),
+        (84, 84, 'World Cup', 'round_32', NULL, 'Winner 84 A', 'Winner 84 B', 'Toronto', '2026-07-02 23:00:00', 'closed'),
+        (85, 85, 'World Cup', 'round_32', NULL, 'Winner 85 A', 'Winner 85 B', 'Vancouver', '2026-07-03 03:00:00', 'closed'),
+        (86, 86, 'World Cup', 'round_32', NULL, 'Winner 86 A', 'Winner 86 B', 'Arlington', '2026-07-03 18:00:00', 'closed'),
+        (87, 87, 'World Cup', 'round_32', NULL, 'Winner 87 A', 'Winner 87 B', 'Miami Gardens', '2026-07-03 22:00:00', 'closed'),
+        (88, 88, 'World Cup', 'round_32', NULL, 'Winner 88 A', 'Winner 88 B', 'Kansas City', '2026-07-04 01:30:00', 'closed'),
+        (89, 89, 'World Cup', 'round_16', NULL, 'Winner 89 A', 'Winner 89 B', 'Houston', '2026-07-04 17:00:00', 'closed'),
+        (90, 90, 'World Cup', 'round_16', NULL, 'Winner 90 A', 'Winner 90 B', 'Philadelphia', '2026-07-04 21:00:00', 'closed'),
+        (91, 91, 'World Cup', 'round_16', NULL, 'Winner 91 A', 'Winner 91 B', 'East Rutherford', '2026-07-05 20:00:00', 'closed'),
+        (92, 92, 'World Cup', 'round_16', NULL, 'Winner 92 A', 'Winner 92 B', 'Mexico City', '2026-07-06 00:00:00', 'closed'),
+        (93, 93, 'World Cup', 'round_16', NULL, 'Winner 93 A', 'Winner 93 B', 'Arlington', '2026-07-06 19:00:00', 'closed'),
+        (94, 94, 'World Cup', 'round_16', NULL, 'Winner 94 A', 'Winner 94 B', 'Seattle', '2026-07-07 00:00:00', 'closed'),
+        (95, 95, 'World Cup', 'round_16', NULL, 'Winner 95 A', 'Winner 95 B', 'Atlanta', '2026-07-07 16:00:00', 'closed'),
+        (96, 96, 'World Cup', 'round_16', NULL, 'Winner 96 A', 'Winner 96 B', 'Vancouver', '2026-07-07 20:00:00', 'closed'),
+        (97, 97, 'World Cup', 'quarter_final', NULL, 'Winner 97 A', 'Winner 97 B', 'Foxborough', '2026-07-09 20:00:00', 'closed'),
+        (98, 98, 'World Cup', 'quarter_final', NULL, 'Winner 98 A', 'Winner 98 B', 'Inglewood', '2026-07-10 19:00:00', 'closed'),
+        (99, 99, 'World Cup', 'quarter_final', NULL, 'Winner 99 A', 'Winner 99 B', 'Miami Gardens', '2026-07-11 21:00:00', 'closed'),
+        (100, 100, 'World Cup', 'quarter_final', NULL, 'Winner 100 A', 'Winner 100 B', 'Kansas City', '2026-07-12 01:00:00', 'closed'),
+        (101, 101, 'World Cup', 'semi_final', NULL, 'Winner 101 A', 'Winner 101 B', 'Arlington', '2026-07-14 19:00:00', 'closed'),
+        (102, 102, 'World Cup', 'semi_final', NULL, 'Winner 102 A', 'Winner 102 B', 'Atlanta', '2026-07-15 19:00:00', 'closed'),
+        (103, 103, 'World Cup', 'third_place', NULL, 'Winner 103 A', 'Winner 103 B', 'Miami Gardens', '2026-07-18 21:00:00', 'closed'),
+        (104, 104, 'World Cup', 'final', NULL, 'Winner 104 A', 'Winner 104 B', 'East Rutherford', '2026-07-19 19:00:00', 'closed')
+    ) AS source (
+        match_id,
+        match_no,
+        competition,
+        stage,
+        group_name,
+        home_team,
+        away_team,
+        venue,
+        kickoff_at,
+        status
+    )
+    ON target.match_id = source.match_id
+    WHEN MATCHED THEN
+        UPDATE SET
+            match_no = source.match_no,
+            competition = source.competition,
+            stage = source.stage,
+            group_name = source.group_name,
+            home_team = source.home_team,
+            away_team = source.away_team,
+            venue = source.venue,
+            kickoff_at = source.kickoff_at,
+            status = source.status,
+            updated_at = GETDATE()
+    WHEN NOT MATCHED THEN
+        INSERT (match_id, match_no, competition, stage, group_name, home_team, away_team, venue, kickoff_at, status)
+        VALUES (
+            source.match_id,
+            source.match_no,
+            source.competition,
+            source.stage,
+            source.group_name,
+            source.home_team,
+            source.away_team,
+            source.venue,
+            source.kickoff_at,
+            source.status
+        );
+
+    SET IDENTITY_INSERT agent.dbo.world_cup_matches OFF;
+END;
+
+IF OBJECT_ID('agent.dbo.world_cup_odds', 'U') IS NOT NULL
+BEGIN
+    MERGE agent.dbo.world_cup_odds AS target
+    USING (VALUES
+        (1, '1x2', 'home', 1.40, 0),
+        (1, '1x2', 'draw', 4.60, 0),
+        (1, '1x2', 'away', 8.00, 0),
+        (2, '1x2', 'home', 2.62, 0),
+        (2, '1x2', 'draw', 3.10, 0),
+        (2, '1x2', 'away', 2.75, 0),
+        (7, '1x2', 'home', 2.05, 0),
+        (7, '1x2', 'draw', 3.30, 0),
+        (7, '1x2', 'away', 3.90, 0),
+        (5, '1x2', 'home', 1.61, 0),
+        (5, '1x2', 'draw', 3.90, 0),
+        (5, '1x2', 'away', 5.50, 0),
+        (6, '1x2', 'home', 6.25, 0),
+        (6, '1x2', 'draw', 4.33, 0),
+        (6, '1x2', 'away', 1.50, 0),
+        (8, '1x2', 'home', 4.40, 0),
+        (8, '1x2', 'draw', 3.50, 0),
+        (8, '1x2', 'away', 1.80, 0),
+        (9, '1x2', 'home', 1.04, 0),
+        (9, '1x2', 'draw', 16.00, 0),
+        (9, '1x2', 'away', 46.00, 0),
+        (11, '1x2', 'home', 2.00, 0),
+        (11, '1x2', 'draw', 3.50, 0),
+        (11, '1x2', 'away', 3.50, 0),
+        (10, '1x2', 'home', 3.50, 0),
+        (10, '1x2', 'draw', 2.88, 0),
+        (10, '1x2', 'away', 2.30, 0),
+        (12, '1x2', 'home', 1.91, 0),
+        (12, '1x2', 'draw', 3.30, 0),
+        (12, '1x2', 'away', 4.00, 0),
+        (15, '1x2', 'home', 1.11, 0),
+        (15, '1x2', 'draw', 10.00, 0),
+        (15, '1x2', 'away', 23.00, 0),
+        (NULL, 'winner', 'Spain', 5.50, 999),
+        (NULL, 'winner', 'France', 5.80, 998),
+        (NULL, 'winner', 'England', 7.50, 997),
+        (NULL, 'winner', 'Brazil', 10.00, 996),
+        (NULL, 'winner', 'Portugal', 9.50, 995),
+        (NULL, 'winner', 'Argentina', 10.50, 994),
+        (NULL, 'winner', 'Germany', 14.00, 993),
+        (NULL, 'winner', 'Netherlands', 18.00, 992),
+        (NULL, 'winner', 'Belgium', 23.00, 991),
+        (NULL, 'winner', 'Norway', 34.00, 990),
+        (NULL, 'winner', 'Colombia', 41.00, 989),
+        (NULL, 'winner', 'Japan', 46.00, 988),
+        (NULL, 'winner', 'Morocco', 56.00, 987),
+        (NULL, 'winner', 'United States', 56.00, 986),
+        (NULL, 'winner', 'Uruguay', 61.00, 985),
+        (NULL, 'winner', 'Mexico', 61.00, 984),
+        (NULL, 'winner', 'Switzerland', 76.00, 983),
+        (NULL, 'winner', 'Croatia', 81.00, 982),
+        (NULL, 'winner', 'Ecuador', 101.00, 981),
+        (NULL, 'winner', 'Austria', 101.00, 980),
+        (NULL, 'winner', 'Turkiye', 81.00, 979),
+        (NULL, 'winner', 'Senegal', 151.00, 978),
+        (NULL, 'winner', 'Ivory Coast', 151.00, 977),
+        (NULL, 'winner', 'Sweden', 176.00, 976),
+        (NULL, 'winner', 'Canada', 201.00, 975),
+        (NULL, 'winner', 'Paraguay', 226.00, 974),
+        (NULL, 'winner', 'Bosnia & Herzegovina', 226.00, 973),
+        (NULL, 'winner', 'Algeria', 251.00, 972),
+        (NULL, 'winner', 'Scotland', 301.00, 971),
+        (NULL, 'winner', 'Egypt', 351.00, 970),
+        (NULL, 'winner', 'South Korea', 401.00, 969),
+        (NULL, 'winner', 'Ghana', 401.00, 968),
+        (NULL, 'winner', 'Czechia', 401.00, 967),
+        (NULL, 'winner', 'Iran', 751.00, 966),
+        (NULL, 'winner', 'Tunisia', 2001.00, 965),
+        (NULL, 'winner', 'Australia', 2501.00, 964),
+        (NULL, 'winner', 'DR Congo', 2501.00, 963),
+        (NULL, 'winner', 'Cape Verde', 2501.00, 962),
+        (NULL, 'winner', 'Uzbekistan', 2501.00, 961),
+        (NULL, 'winner', 'Panama', 2501.00, 960),
+        (NULL, 'winner', 'Curacao', 2501.00, 959),
+        (NULL, 'winner', 'Haiti', 1501.00, 958),
+        (NULL, 'winner', 'Qatar', 2501.00, 957),
+        (NULL, 'winner', 'Saudi Arabia', 2501.00, 956),
+        (NULL, 'winner', 'Iraq', 2501.00, 955),
+        (NULL, 'winner', 'New Zealand', 2501.00, 954),
+        (NULL, 'winner', 'Jordan', 2501.00, 953),
+        (NULL, 'winner', 'South Africa', 2501.00, 952)
+    ) AS source (match_id, market, selection, decimal_odds, locked_weight)
+    ON target.market = source.market
+        AND (
+            target.match_id = source.match_id
+            OR (target.match_id IS NULL AND source.match_id IS NULL)
+        )
+        AND target.selection = source.selection
+    WHEN MATCHED THEN
+        UPDATE SET
+            decimal_odds = source.decimal_odds,
+            previous_odds = target.decimal_odds,
+            is_active = 1,
+            locked_weight = source.locked_weight,
+            updated_at = GETDATE()
+    WHEN NOT MATCHED THEN
+        INSERT (match_id, market, selection, decimal_odds, previous_odds, is_active, locked_weight, updated_at)
+        VALUES (
+            source.match_id,
+            source.market,
+            source.selection,
+            source.decimal_odds,
+            NULL,
+            1,
+            source.locked_weight,
+            GETDATE()
+        );
+END;
+
+USE [agent]
+GO
+
+DECLARE @WorldCupDescriptions TABLE (
+    table_name SYSNAME NOT NULL,
+    column_name SYSNAME NULL,
+    description NVARCHAR(4000) NOT NULL
+);
+
+INSERT INTO @WorldCupDescriptions (table_name, column_name, description)
+VALUES
+    (N'world_cup_user_state', NULL, N'世界杯活动用户状态表'),
+    (N'world_cup_user_state', N'user_id', N'活动用户 UserID,对应 QPAccountsDB.dbo.AccountsInfo.UserID'),
+    (N'world_cup_user_state', N'first_bet_used', N'是否已使用世界杯首注奖励,1=已使用,0=未使用'),
+    (N'world_cup_user_state', N'invite_code', N'用户自己的邀请码,前端展示并用于手动绑定'),
+    (N'world_cup_user_state', N'referred_by_user_id', N'邀请人的 UserID,未绑定时为空'),
+    (N'world_cup_user_state', N'referral_bind_at', N'邀请关系绑定时间'),
+    (N'world_cup_user_state', N'referral_bind_type', N'绑定方式,link=分享链接自动绑定,manual=24h 内手动输入邀请码'),
+    (N'world_cup_user_state', N'device_fp', N'设备指纹,用于风控识别同设备自邀'),
+    (N'world_cup_user_state', N'pay_account_hash', N'支付账号哈希,用于风控识别同支付账号'),
+    (N'world_cup_user_state', N'signup_ip', N'注册 IP,用于风控识别同 IP 或同网段聚集'),
+    (N'world_cup_user_state', N'created_at', N'记录创建时间'),
+    (N'world_cup_user_state', N'updated_at', N'记录更新时间'),
+
+    (N'world_cup_matches', NULL, N'世界杯单场比赛表'),
+    (N'world_cup_matches', N'match_id', N'比赛 ID,单场 1X2 盘口主键'),
+    (N'world_cup_matches', N'match_no', N'FIFA 官方赛程 Match 编号'),
+    (N'world_cup_matches', N'competition', N'赛事名称,例如 World Cup'),
+    (N'world_cup_matches', N'stage', N'赛事阶段,group=小组赛,round_32=32 强,round_16=16 强,quarter_final=1/4 决赛,semi_final=半决赛,third_place=季军赛,final=决赛'),
+    (N'world_cup_matches', N'group_name', N'小组名称,小组赛为 A-L,淘汰赛为空'),
+    (N'world_cup_matches', N'home_team', N'主队名称'),
+    (N'world_cup_matches', N'away_team', N'客队名称'),
+    (N'world_cup_matches', N'venue', N'比赛城市或场馆名称'),
+    (N'world_cup_matches', N'kickoff_at', N'开赛时间;开赛前 1 小时停止投注'),
+    (N'world_cup_matches', N'status', N'比赛状态,scheduled=待开赛可投注,closed=关闭投注,finished=已完赛'),
+    (N'world_cup_matches', N'result', N'比赛结果,home/draw/away;未结算时为空'),
+    (N'world_cup_matches', N'created_at', N'记录创建时间'),
+    (N'world_cup_matches', N'updated_at', N'记录更新时间'),
+
+    (N'world_cup_odds', NULL, N'世界杯盘口赔率表'),
+    (N'world_cup_odds', N'odds_id', N'赔率记录 ID'),
+    (N'world_cup_odds', N'match_id', N'比赛 ID;Winner 夺冠盘为空,1X2 单场盘必填'),
+    (N'world_cup_odds', N'market', N'盘口类型,winner=夺冠盘,1x2=单场胜平负'),
+    (N'world_cup_odds', N'selection', N'投注选项;Winner 为球队名,1X2 为 home/draw/away'),
+    (N'world_cup_odds', N'decimal_odds', N'当前十进制赔率'),
+    (N'world_cup_odds', N'previous_odds', N'上一版赔率,用于前端展示涨跌箭头'),
+    (N'world_cup_odds', N'is_active', N'是否展示并允许投注,1=启用,0=隐藏'),
+    (N'world_cup_odds', N'locked_weight', N'后台排序权重,数值越大越靠前'),
+    (N'world_cup_odds', N'updated_at', N'赔率更新时间'),
+
+    (N'world_cup_match_favorites', NULL, N'世界杯比赛收藏表'),
+    (N'world_cup_match_favorites', N'id', N'收藏记录 ID'),
+    (N'world_cup_match_favorites', N'user_id', N'收藏用户 UserID'),
+    (N'world_cup_match_favorites', N'match_id', N'被收藏的单场比赛 ID,仅支持 1X2 比赛,不支持 Winner 盘'),
+    (N'world_cup_match_favorites', N'created_at', N'收藏创建时间'),
+
+    (N'world_cup_bets', NULL, N'世界杯投注订单表'),
+    (N'world_cup_bets', N'bet_id', N'世界杯注单 ID'),
+    (N'world_cup_bets', N'user_id', N'下注用户 UserID'),
+    (N'world_cup_bets', N'game_id', N'前端展示的 8 位 GameID,冗余保存便于查询'),
+    (N'world_cup_bets', N'idempotency_key', N'客户端幂等键,同一用户同一 key 不重复下单'),
+    (N'world_cup_bets', N'market', N'盘口类型,winner=夺冠盘,1x2=单场胜平负'),
+    (N'world_cup_bets', N'match_id', N'比赛 ID;Winner 夺冠盘为空'),
+    (N'world_cup_bets', N'selection', N'投注选项,球队名或 home/draw/away'),
+    (N'world_cup_bets', N'stake', N'下注金额,整数分'),
+    (N'world_cup_bets', N'odds', N'下单时锁定的十进制赔率'),
+    (N'world_cup_bets', N'is_first_bet', N'是否为用户世界杯首注,1=首注'),
+    (N'world_cup_bets', N'bonus_amount', N'首注 +50% 额外奖励金额,整数分'),
+    (N'world_cup_bets', N'potential_payout', N'潜在派彩金额,整数分,stake * odds + bonus_amount'),
+    (N'world_cup_bets', N'status', N'注单状态,pending=待结算,won=赢,lost=输'),
+    (N'world_cup_bets', N'settled_at', N'结算时间'),
+    (N'world_cup_bets', N'created_at', N'下单时间'),
+    (N'world_cup_bets', N'updated_at', N'注单更新时间'),
+
+    (N'world_cup_referrals', NULL, N'世界杯邀请绑定关系表'),
+    (N'world_cup_referrals', N'id', N'邀请绑定记录 ID'),
+    (N'world_cup_referrals', N'referrer_id', N'邀请人 UserID'),
+    (N'world_cup_referrals', N'invitee_id', N'被邀请人 UserID;唯一,一个被邀请人只能绑定一次'),
+    (N'world_cup_referrals', N'bind_type', N'绑定方式,link=分享链接自动绑定,manual=手动补填邀请码'),
+    (N'world_cup_referrals', N'bind_at', N'绑定发生时间'),
+    (N'world_cup_referrals', N'created_at', N'记录创建时间'),
+
+    (N'world_cup_referral_rewards', NULL, N'世界杯邀请奖励审核表'),
+    (N'world_cup_referral_rewards', N'reward_id', N'邀请奖励单 ID,后台审核对象'),
+    (N'world_cup_referral_rewards', N'referrer_id', N'邀请人 UserID'),
+    (N'world_cup_referral_rewards', N'invitee_id', N'被邀请人 UserID;同一被邀请人首充只生成一次奖励单'),
+    (N'world_cup_referral_rewards', N'first_deposit_order_sn', N'触发邀请奖励的首充订单号,用于 OrderPaid 事件监听幂等'),
+    (N'world_cup_referral_rewards', N'first_deposit_amt', N'触发奖励时的首充实际入账金额,整数分'),
+    (N'world_cup_referral_rewards', N'reward_each', N'双方各得奖励金额,整数分;规则为 min(first_deposit_amt * 50%, 10000)'),
+    (N'world_cup_referral_rewards', N'total_liability', N'本奖励单总赔付,整数分,reward_each * 2,最高 20000'),
+    (N'world_cup_referral_rewards', N'risk_score', N'风控评分,0-100'),
+    (N'world_cup_referral_rewards', N'risk_level', N'风险等级,low/medium/high'),
+    (N'world_cup_referral_rewards', N'signals', N'命中的风控信号 JSON 文本'),
+    (N'world_cup_referral_rewards', N'status', N'审核状态,reviewing/approved/rejected/on_hold/clawed_back'),
+    (N'world_cup_referral_rewards', N'reason_code', N'驳回或追回原因代码'),
+    (N'world_cup_referral_rewards', N'review_by', N'审核员标识'),
+    (N'world_cup_referral_rewards', N'reviewed_at', N'审核时间'),
+    (N'world_cup_referral_rewards', N'submitted_at', N'奖励单提交审核时间,用于计算 24h SLA'),
+    (N'world_cup_referral_rewards', N'created_at', N'记录创建时间'),
+    (N'world_cup_referral_rewards', N'updated_at', N'记录更新时间'),
+
+    (N'world_cup_risk_signals', NULL, N'世界杯风控信号配置表'),
+    (N'world_cup_risk_signals', N'code', N'风控信号代码'),
+    (N'world_cup_risk_signals', N'label', N'后台展示名称'),
+    (N'world_cup_risk_signals', N'weight', N'风控权重,命中后累加到 risk_score'),
+    (N'world_cup_risk_signals', N'is_active', N'是否启用,1=启用,0=停用'),
+    (N'world_cup_risk_signals', N'updated_at', N'配置更新时间'),
+
+    (N'world_cup_audit_log', NULL, N'世界杯审核操作日志表'),
+    (N'world_cup_audit_log', N'id', N'审核日志 ID'),
+    (N'world_cup_audit_log', N'reward_id', N'关联的邀请奖励单 ID,可为空'),
+    (N'world_cup_audit_log', N'actor', N'操作人,后台审核员或系统任务'),
+    (N'world_cup_audit_log', N'action', N'操作动作,例如 approve/reject/hold/clawback/auto_score'),
+    (N'world_cup_audit_log', N'reason_code', N'操作原因代码'),
+    (N'world_cup_audit_log', N'before_status', N'操作前状态'),
+    (N'world_cup_audit_log', N'after_status', N'操作后状态'),
+    (N'world_cup_audit_log', N'payload', N'操作上下文 JSON 文本'),
+    (N'world_cup_audit_log', N'created_at', N'日志创建时间');
+
+DECLARE
+    @tableName SYSNAME,
+    @columnName SYSNAME,
+    @description NVARCHAR(4000),
+    @objectName NVARCHAR(512);
+
+DECLARE world_cup_description_cursor CURSOR LOCAL FAST_FORWARD FOR
+SELECT table_name, column_name, description
+FROM @WorldCupDescriptions;
+
+OPEN world_cup_description_cursor;
+
+FETCH NEXT FROM world_cup_description_cursor INTO @tableName, @columnName, @description;
+
+WHILE @@FETCH_STATUS = 0
+BEGIN
+    SET @objectName = N'dbo.' + @tableName;
+
+    IF OBJECT_ID(@objectName, 'U') IS NOT NULL
+        AND (@columnName IS NULL OR COL_LENGTH(@objectName, @columnName) IS NOT NULL)
+    BEGIN
+        IF @columnName IS NULL
+        BEGIN
+            IF EXISTS (
+                SELECT 1
+                FROM sys.extended_properties ep
+                INNER JOIN sys.tables t ON ep.major_id = t.object_id
+                INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
+                WHERE ep.name = N'MS_Description'
+                    AND ep.minor_id = 0
+                    AND s.name = N'dbo'
+                    AND t.name = @tableName
+            )
+            BEGIN
+                EXEC sys.sp_updateextendedproperty
+                    @name = N'MS_Description',
+                    @value = @description,
+                    @level0type = N'SCHEMA',
+                    @level0name = N'dbo',
+                    @level1type = N'TABLE',
+                    @level1name = @tableName;
+            END
+            ELSE
+            BEGIN
+                EXEC sys.sp_addextendedproperty
+                    @name = N'MS_Description',
+                    @value = @description,
+                    @level0type = N'SCHEMA',
+                    @level0name = N'dbo',
+                    @level1type = N'TABLE',
+                    @level1name = @tableName;
+            END;
+        END
+        ELSE
+        BEGIN
+            IF EXISTS (
+                SELECT 1
+                FROM sys.extended_properties ep
+                INNER JOIN sys.tables t ON ep.major_id = t.object_id
+                INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
+                INNER JOIN sys.columns c ON ep.major_id = c.object_id
+                    AND ep.minor_id = c.column_id
+                WHERE ep.name = N'MS_Description'
+                    AND s.name = N'dbo'
+                    AND t.name = @tableName
+                    AND c.name = @columnName
+            )
+            BEGIN
+                EXEC sys.sp_updateextendedproperty
+                    @name = N'MS_Description',
+                    @value = @description,
+                    @level0type = N'SCHEMA',
+                    @level0name = N'dbo',
+                    @level1type = N'TABLE',
+                    @level1name = @tableName,
+                    @level2type = N'COLUMN',
+                    @level2name = @columnName;
+            END
+            ELSE
+            BEGIN
+                EXEC sys.sp_addextendedproperty
+                    @name = N'MS_Description',
+                    @value = @description,
+                    @level0type = N'SCHEMA',
+                    @level0name = N'dbo',
+                    @level1type = N'TABLE',
+                    @level1name = @tableName,
+                    @level2type = N'COLUMN',
+                    @level2name = @columnName;
+            END;
+        END;
+    END;
+
+    FETCH NEXT FROM world_cup_description_cursor INTO @tableName, @columnName, @description;
+END;
+
+CLOSE world_cup_description_cursor;
+DEALLOCATE world_cup_description_cursor;
+GO

+ 104 - 0
resources/views/admin/world_cup/bets.blade.php

@@ -0,0 +1,104 @@
+@extends('base.base')
+@section('base')
+    <div class="main-panel">
+        <div class="content-wrapper">
+            <div class="page-header">
+                <h3 class="page-title">
+                    <span class="page-title-icon bg-gradient-primary text-white mr-2">
+                        <i class="mdi mdi-format-list-bulleted"></i>
+                    </span>
+                    World Cup 下注日志
+                </h3>
+                <nav aria-label="breadcrumb">
+                    <ol class="breadcrumb">
+                        <li class="breadcrumb-item"><a href="#">活动管理</a></li>
+                        <li class="breadcrumb-item active" aria-current="page">下注日志</li>
+                    </ol>
+                </nav>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">筛选</h4>
+                            <form class="form-inline" method="get" action="/admin/world-cup/bets">
+                                <input class="form-control mr-2 mb-2" name="game_id" value="{{ $filters['game_id'] ?? '' }}" placeholder="GameID">
+                                <input class="form-control mr-2 mb-2" name="user_id" value="{{ $filters['user_id'] ?? '' }}" placeholder="UserID">
+                                <select class="form-control mr-2 mb-2" name="market">
+                                    <option value="">全部盘口</option>
+                                    <option value="1x2" {{ ($filters['market'] ?? '') === '1x2' ? 'selected' : '' }}>1X2</option>
+                                    <option value="winner" {{ ($filters['market'] ?? '') === 'winner' ? 'selected' : '' }}>Winner</option>
+                                </select>
+                                <select class="form-control mr-2 mb-2" name="status">
+                                    <option value="">全部状态</option>
+                                    <option value="pending" {{ ($filters['status'] ?? '') === 'pending' ? 'selected' : '' }}>Pending</option>
+                                    <option value="won" {{ ($filters['status'] ?? '') === 'won' ? 'selected' : '' }}>Won</option>
+                                    <option value="lost" {{ ($filters['status'] ?? '') === 'lost' ? 'selected' : '' }}>Lost</option>
+                                </select>
+                                <input class="form-control mr-2 mb-2" name="limit" value="{{ $filters['limit'] ?? 100 }}" placeholder="Limit">
+                                <button class="btn btn-outline-primary btn-sm mb-2" type="submit">筛选</button>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">下注列表</h4>
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th>GameID</th>
+                                        <th>UserID</th>
+                                        <th>盘口</th>
+                                        <th>比赛ID</th>
+                                        <th>选项</th>
+                                        <th>下注金额</th>
+                                        <th>赔率</th>
+                                        <th>奖励金额</th>
+                                        <th>潜在派奖</th>
+                                        <th>状态</th>
+                                        <th>下注时间</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($list as $row)
+                                        <tr>
+                                            <td>
+                                                <a href="{{ $row['user_detail_url'] }}"
+                                                    target="_blank"
+                                                    rel="noopener noreferrer">
+                                                    {{ $row['game_id'] }}
+                                                </a>
+                                            </td>
+                                            <td>{{ $row['user_id'] }}</td>
+                                            <td>{{ $row['market'] }}</td>
+                                            <td>{{ $row['match_label'] }}</td>
+                                            <td>{{ $row['selection_label'] }}</td>
+                                            <td>{{ $row['stake_text'] }}</td>
+                                            <td>{{ number_format($row['odds'], 2) }}</td>
+                                            <td>{{ $row['bonus_text'] }}</td>
+                                            <td>{{ $row['potential_payout_text'] }}</td>
+                                            <td>{{ $row['status_label'] }}</td>
+                                            <td>{{ $row['created_at'] ?? '-' }}</td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="11" class="text-center">暂无数据</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+@endsection

+ 321 - 0
resources/views/admin/world_cup/kpi.blade.php

@@ -0,0 +1,321 @@
+@extends('base.base')
+@section('base')
+    @php
+        $reviewingCount = (int)($kpi['reviewing_count'] ?? 0);
+        $approvedCount = (int)($kpi['approved_count'] ?? 0);
+        $rejectedCount = (int)($kpi['rejected_count'] ?? 0);
+        $paidLiability = (int)($kpi['paid_liability'] ?? 0);
+        $decisionCount = $approvedCount + $rejectedCount;
+        $approvalRate = $decisionCount > 0 ? round($approvedCount * 100 / $decisionCount, 1) : 0;
+        $rejectedRate = $decisionCount > 0 ? round($rejectedCount * 100 / $decisionCount, 1) : 0;
+        $totalCount = $reviewingCount + $decisionCount;
+        $reviewingRate = $totalCount > 0 ? round($reviewingCount * 100 / $totalCount, 1) : 0;
+    @endphp
+
+    <style>
+        .world-cup-kpi-band {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            gap: 12px;
+        }
+
+        .world-cup-kpi-value {
+            font-size: 28px;
+            line-height: 1.1;
+            font-weight: 600;
+        }
+
+        .world-cup-kpi-label {
+            margin-bottom: 10px;
+            font-size: 14px;
+            opacity: 0.9;
+        }
+
+        .world-cup-progress {
+            height: 10px;
+            border-radius: 6px;
+            background: #edf2f7;
+            overflow: hidden;
+        }
+
+        .world-cup-progress span {
+            display: block;
+            height: 100%;
+        }
+
+        .world-cup-section-action {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            gap: 12px;
+            margin-bottom: 16px;
+        }
+
+        @media screen and (max-width: 767px) {
+            .world-cup-section-action {
+                align-items: flex-start;
+                flex-direction: column;
+            }
+        }
+    </style>
+
+    <div class="main-panel">
+        <div class="content-wrapper">
+            <div class="page-header">
+                <h3 class="page-title">
+                    <span class="page-title-icon bg-gradient-primary text-white mr-2">
+                        <i class="mdi mdi-chart-bar"></i>
+                    </span>
+                    World Cup KPI 看板
+                </h3>
+                <nav aria-label="breadcrumb">
+                    <ol class="breadcrumb">
+                        <li class="breadcrumb-item"><a href="#">活动管理</a></li>
+                        <li class="breadcrumb-item active" aria-current="page">KPI 看板</li>
+                    </ol>
+                </nav>
+            </div>
+
+            <div class="row">
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-warning text-white">
+                        <div class="card-body">
+                            <div class="world-cup-kpi-band">
+                                <div>
+                                    <div class="world-cup-kpi-label">待审核奖励</div>
+                                    <div class="world-cup-kpi-value">{{ $reviewingCount }}</div>
+                                </div>
+                                <i class="mdi mdi-timer-sand" style="font-size: 34px;"></i>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-success text-white">
+                        <div class="card-body">
+                            <div class="world-cup-kpi-band">
+                                <div>
+                                    <div class="world-cup-kpi-label">已通过奖励</div>
+                                    <div class="world-cup-kpi-value">{{ $approvedCount }}</div>
+                                </div>
+                                <i class="mdi mdi-check-circle-outline" style="font-size: 34px;"></i>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-danger text-white">
+                        <div class="card-body">
+                            <div class="world-cup-kpi-band">
+                                <div>
+                                    <div class="world-cup-kpi-label">已驳回奖励</div>
+                                    <div class="world-cup-kpi-value">{{ $rejectedCount }}</div>
+                                </div>
+                                <i class="mdi mdi-close-circle-outline" style="font-size: 34px;"></i>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-info text-white">
+                        <div class="card-body">
+                            <div class="world-cup-kpi-band">
+                                <div>
+                                    <div class="world-cup-kpi-label">已赔付金额</div>
+                                    <div class="world-cup-kpi-value">${{ number_format($paidLiability / 100, 2) }}</div>
+                                </div>
+                                <i class="mdi mdi-cash-multiple" style="font-size: 34px;"></i>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-5 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <div class="world-cup-section-action">
+                                <h4 class="card-title mb-0">审核漏斗</h4>
+                                <div>
+                                    <a class="btn btn-gradient-primary btn-sm" href="/admin/world-cup/rewards">进入审核队列</a>
+                                    <a class="btn btn-outline-primary btn-sm" href="/admin/world-cup/schedule">赛程更新</a>
+                                </div>
+                            </div>
+                            <div class="mb-4">
+                                <div class="d-flex justify-content-between mb-2">
+                                    <span>待审核占比</span>
+                                    <span>{{ $reviewingRate }}%</span>
+                                </div>
+                                <div class="world-cup-progress">
+                                    <span style="width: {{ $reviewingRate }}%; background: #fed713;"></span>
+                                </div>
+                            </div>
+                            <div class="mb-4">
+                                <div class="d-flex justify-content-between mb-2">
+                                    <span>通过率</span>
+                                    <span>{{ $approvalRate }}%</span>
+                                </div>
+                                <div class="world-cup-progress">
+                                    <span style="width: {{ $approvalRate }}%; background: #1bcfb4;"></span>
+                                </div>
+                            </div>
+                            <div>
+                                <div class="d-flex justify-content-between mb-2">
+                                    <span>驳回率</span>
+                                    <span>{{ $rejectedRate }}%</span>
+                                </div>
+                                <div class="world-cup-progress">
+                                    <span style="width: {{ $rejectedRate }}%; background: #fe7c96;"></span>
+                                </div>
+                            </div>
+                            <div class="table-responsive mt-4">
+                                <table class="table table-bordered">
+                                    <tbody>
+                                    <tr>
+                                        <th>已处理总数</th>
+                                        <td>{{ $decisionCount }}</td>
+                                    </tr>
+                                    <tr>
+                                        <th>当前队列总数</th>
+                                        <td>{{ $totalCount }}</td>
+                                    </tr>
+                                    <tr>
+                                        <th>已赔付总额</th>
+                                        <td>${{ number_format($paidLiability / 100, 2) }}</td>
+                                    </tr>
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="col-lg-7 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <div class="world-cup-section-action">
+                                <h4 class="card-title mb-0">High Risk 待审</h4>
+                                <a class="btn btn-outline-danger btn-sm" href="/admin/world-cup/rewards?status=reviewing&risk=high">只看高风险</a>
+                            </div>
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th>Reward ID</th>
+                                        <th>邀请人</th>
+                                        <th>被邀请人</th>
+                                        <th>首充</th>
+                                        <th>赔付</th>
+                                        <th>风险</th>
+                                        <th>提交时间</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($highRiskList as $row)
+                                        <tr>
+                                            <td>{{ $row['reward_id'] }}</td>
+                                            <td>{{ $row['referrer_id'] }}</td>
+                                            <td>{{ $row['invitee_id'] }}</td>
+                                            <td>${{ number_format(($row['first_deposit_amt'] ?? 0) / 100, 2) }}</td>
+                                            <td>${{ number_format(($row['total_liability'] ?? 0) / 100, 2) }}</td>
+                                            <td>
+                                                <span class="badge badge-danger">
+                                                    {{ $row['risk_level'] ?? 'high' }} / {{ $row['risk_score'] ?? 0 }}
+                                                </span>
+                                            </td>
+                                            <td>{{ $row['submitted_at'] ?? '-' }}</td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="7" class="text-center text-muted">暂无 High Risk 待审奖励</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-6 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">最新待审核</h4>
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th>Reward ID</th>
+                                        <th>邀请人</th>
+                                        <th>被邀请人</th>
+                                        <th>风险</th>
+                                        <th>提交时间</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($reviewingList as $row)
+                                        <tr>
+                                            <td>{{ $row['reward_id'] }}</td>
+                                            <td>{{ $row['referrer_id'] }}</td>
+                                            <td>{{ $row['invitee_id'] }}</td>
+                                            <td>{{ $row['risk_level'] ?? '-' }}</td>
+                                            <td>{{ $row['submitted_at'] ?? '-' }}</td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="5" class="text-center text-muted">暂无待审核奖励</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="col-lg-6 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <div class="world-cup-section-action">
+                                <h4 class="card-title mb-0">最近审核日志</h4>
+                                <a class="btn btn-outline-primary btn-sm" href="/admin/world-cup/logs">查看全部日志</a>
+                            </div>
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th>Reward ID</th>
+                                        <th>操作人</th>
+                                        <th>动作</th>
+                                        <th>状态变化</th>
+                                        <th>时间</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($logs as $log)
+                                        <tr>
+                                            <td>{{ $log['reward_id'] ?? '-' }}</td>
+                                            <td>{{ $log['actor'] ?? '-' }}</td>
+                                            <td>{{ $log['action'] ?? '-' }}</td>
+                                            <td>{{ $log['before_status'] ?? '-' }} -> {{ $log['after_status'] ?? '-' }}</td>
+                                            <td>{{ $log['created_at'] ?? '-' }}</td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="5" class="text-center text-muted">暂无审核日志</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+@endsection

+ 137 - 0
resources/views/admin/world_cup/logs.blade.php

@@ -0,0 +1,137 @@
+@extends('base.base')
+@section('base')
+    <div class="main-panel">
+        <div class="content-wrapper">
+            <div class="page-header">
+                <h3 class="page-title">
+                    <span class="page-title-icon bg-gradient-primary text-white mr-2">
+                        <i class="mdi mdi-file-document-box"></i>
+                    </span>
+                    World Cup 审核日志
+                </h3>
+                <nav aria-label="breadcrumb">
+                    <ol class="breadcrumb">
+                        <li class="breadcrumb-item"><a href="#">活动管理</a></li>
+                        <li class="breadcrumb-item active" aria-current="page">审核日志</li>
+                    </ol>
+                </nav>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">筛选</h4>
+                            <form class="form-inline" method="get" action="/admin/world-cup/logs">
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">Reward ID</label>
+                                    <input class="form-control" name="reward_id" value="{{ $filters['reward_id'] ?? '' }}" placeholder="奖励单 ID">
+                                </div>
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">操作人</label>
+                                    <input class="form-control" name="actor" value="{{ $filters['actor'] ?? '' }}" placeholder="admin">
+                                </div>
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">动作</label>
+                                    <select class="form-control" name="action">
+                                        @foreach(['' => '全部', 'approve' => 'approve', 'reject' => 'reject', 'hold' => 'hold', 'clawback' => 'clawback', 'schedule_update' => 'schedule_update'] as $value => $label)
+                                            <option value="{{ $value }}" @if(($filters['action'] ?? '') === $value) selected @endif>{{ $label }}</option>
+                                        @endforeach
+                                    </select>
+                                </div>
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">条数</label>
+                                    <input class="form-control" name="limit" value="{{ $filters['limit'] ?? 100 }}" style="width: 90px;">
+                                </div>
+                                <button type="submit" class="btn btn-gradient-primary btn-sm mb-2">查询</button>
+                                <a class="btn btn-outline-secondary btn-sm mb-2 ml-2" href="/admin/world-cup/logs">重置</a>
+                                <a class="btn btn-outline-secondary btn-sm mb-2 ml-2" href="/admin/world-cup/kpi">返回 KPI</a>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">日志列表</h4>
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th>ID</th>
+                                        <th>Reward ID</th>
+                                        <th>操作人</th>
+                                        <th>动作</th>
+                                        <th>原因</th>
+                                        <th>状态变化</th>
+                                        <th>Payload</th>
+                                        <th>时间</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($logs as $log)
+                                        <tr>
+                                            <td>{{ $log['id'] ?? '-' }}</td>
+                                            <td>{{ $log['reward_id'] ?? '-' }}</td>
+                                            <td>{{ $log['actor'] ?? '-' }}</td>
+                                            <td>{{ $log['action'] ?? '-' }}</td>
+                                            <td>{{ $log['reason_code'] ?? '-' }}</td>
+                                            <td>{{ $log['before_status'] ?? '-' }} -> {{ $log['after_status'] ?? '-' }}</td>
+                                            <td>
+                                                @if(!empty($log['payload']))
+                                                    <button type="button" class="btn btn-outline-info btn-sm js-payload" data-payload="{{ e($log['payload']) }}">查看</button>
+                                                @else
+                                                    -
+                                                @endif
+                                            </td>
+                                            <td>{{ $log['created_at'] ?? '-' }}</td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="8" class="text-center text-muted">暂无日志</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        (function () {
+            $('.js-payload').on('click', function () {
+                var payload = $(this).data('payload') || '';
+                var formatted = payload;
+
+                try {
+                    formatted = JSON.stringify(JSON.parse(payload), null, 2);
+                } catch (e) {
+                    formatted = payload;
+                }
+
+                var content = '<pre style="white-space: pre-wrap; padding: 12px;">'
+                    + $('<div/>').text(formatted).html()
+                    + '</pre>';
+
+                if (window.layer) {
+                    layer.open({
+                        type: 1,
+                        area: ['720px', '620px'],
+                        title: 'Payload',
+                        content: content
+                    });
+                    return;
+                }
+
+                alert(formatted);
+            });
+        })();
+    </script>
+@endsection

+ 449 - 0
resources/views/admin/world_cup/odds.blade.php

@@ -0,0 +1,449 @@
+@extends('base.base')
+@section('base')
+    <div class="main-panel">
+        <div class="content-wrapper">
+            <div class="page-header">
+                <h3 class="page-title">
+                    <span class="page-title-icon bg-gradient-primary text-white mr-2">
+                        <i class="mdi mdi-trophy"></i>
+                    </span>
+                    World Cup 盘口维护
+                </h3>
+                <nav aria-label="breadcrumb">
+                    <ol class="breadcrumb">
+                        <li class="breadcrumb-item"><a href="#">活动管理</a></li>
+                        <li class="breadcrumb-item active" aria-current="page">盘口维护</li>
+                    </ol>
+                </nav>
+            </div>
+
+            @if($result)
+                <div class="alert {{ $result['success'] ? 'alert-success' : 'alert-danger' }}">
+                    {{ $result['success'] ? ($result['message'] ?? '保存成功') : ($result['message'] ?? '保存失败') }}
+                    @if(isset($result['data']['updated']))
+                        <div>已更新:{{ $result['data']['updated'] ?? 0 }}</div>
+                        <div>已跳过:{{ $result['data']['skipped'] ?? 0 }}</div>
+                    @endif
+                    @foreach(($result['data']['errors'] ?? []) as $error)
+                        <div>Row {{ $error['row'] ?? '-' }}: {{ $error['message'] ?? '-' }}</div>
+                    @endforeach
+                </div>
+            @endif
+            <div class="alert d-none" id="world-cup-odds-ajax-result"></div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">新增或更新盘口</h4>
+                            <form class="form-inline" id="world-cup-odds-form" method="post" action="/admin/world-cup/odds">
+                                {{ csrf_field() }}
+                                <select class="form-control mr-2 mb-2" id="world-cup-odds-market" name="market">
+                                    <option value="1x2">1X2 单场</option>
+                                    <option value="winner">Winner 夺冠盘</option>
+                                </select>
+                                <select class="form-control mr-2 mb-2" id="world-cup-odds-match" name="match_id">
+                                    <option value="">Winner 不选比赛</option>
+                                    @foreach($matches as $match)
+                                        <option value="{{ $match['match_id'] }}"
+                                            data-stage="{{ $match['stage'] }}"
+                                            data-home-team="{{ $match['home_team'] }}"
+                                            data-away-team="{{ $match['away_team'] }}">
+                                            #{{ $match['match_no'] }} {{ $match['home_team'] }} vs {{ $match['away_team'] }}
+                                        </option>
+                                    @endforeach
+                                </select>
+                                <select class="form-control mr-2 mb-2" id="world-cup-odds-selection-select" name="selection"></select>
+                                <input class="form-control mr-2 mb-2 d-none" id="world-cup-odds-selection-input" name="selection" disabled placeholder="Winner 球队名">
+                                <input class="form-control mr-2 mb-2" id="world-cup-odds-decimal" name="decimal_odds" placeholder="赔率,如 2.15">
+                                <input class="form-control mr-2 mb-2" id="world-cup-odds-weight" name="locked_weight" value="0" placeholder="排序权重">
+                                <select class="form-control mr-2 mb-2" id="world-cup-odds-active" name="is_active">
+                                    <option value="1">启用</option>
+                                    <option value="0">停用</option>
+                                </select>
+                                <button class="btn btn-gradient-primary btn-sm mb-2" type="submit">保存盘口</button>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">CSV 导入盘口</h4>
+                            <form class="form-inline"
+                                  method="post"
+                                  action="/admin/world-cup/odds/import"
+                                  enctype="multipart/form-data">
+                                {{ csrf_field() }}
+                                <select class="form-control mr-2 mb-2" name="market">
+                                    <option value="1x2">1X2</option>
+                                    <option value="winner">Winner</option>
+                                </select>
+                                <input class="form-control mr-2 mb-2" type="file" name="csv_file" accept=".csv">
+                                <button class="btn btn-gradient-primary btn-sm mb-2" type="submit">导入</button>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">盘口列表</h4>
+                            <form class="form-inline mb-3" method="get" action="/admin/world-cup/odds">
+                                <select class="form-control mr-2 mb-2" name="market">
+                                    <option value="">全部盘口</option>
+                                    <option value="1x2" {{ ($filters['market'] ?? '') === '1x2' ? 'selected' : '' }}>1X2</option>
+                                    <option value="winner" {{ ($filters['market'] ?? '') === 'winner' ? 'selected' : '' }}>Winner</option>
+                                </select>
+                                <input class="form-control mr-2 mb-2" name="match_id" value="{{ $filters['match_id'] ?? '' }}" placeholder="match_id">
+                                <button class="btn btn-outline-primary btn-sm mb-2" type="submit">筛选</button>
+                            </form>
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th>ID</th>
+                                        <th>盘口</th>
+                                        <th>比赛</th>
+                                        <th>队伍</th>
+                                        <th>选项</th>
+                                        <th>当前赔率</th>
+                                        <th>上次赔率</th>
+                                        <th>状态</th>
+                                        <th>权重</th>
+                                        <th>更新时间</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($odds as $row)
+                                        <tr class="world-cup-odds-row"
+                                            style="cursor: pointer;"
+                                            data-market="{{ $row['market'] }}"
+                                            data-match-id="{{ $row['match_id'] }}"
+                                            data-selection="{{ $row['selection'] }}"
+                                            data-decimal-odds="{{ $row['decimal_odds'] }}"
+                                            data-locked-weight="{{ $row['locked_weight'] }}"
+                                            data-is-active="{{ $row['is_active'] }}">
+                                            <td>{{ $row['odds_id'] }}</td>
+                                            <td>{{ $row['market'] }}</td>
+                                            <td>{{ $row['match_id'] ?: '-' }}</td>
+                                            <td>{{ $row['team_pair'] ?? '-' }}</td>
+                                            <td>{{ $row['selection_label'] ?? $row['selection'] }}</td>
+                                            <td class="js-current-odds">{{ $row['decimal_odds'] }}</td>
+                                            <td>{{ $row['previous_odds'] ?? '-' }}</td>
+                                            <td>{{ (int)$row['is_active'] === 1 ? '启用' : '停用' }}</td>
+                                            <td>{{ $row['locked_weight'] }}</td>
+                                            <td>{{ $row['updated_at'] ?? '-' }}</td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="10" class="text-center">暂无数据</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <script>
+        (function () {
+            var form = document.getElementById('world-cup-odds-form');
+            var market = document.getElementById('world-cup-odds-market');
+            var match = document.getElementById('world-cup-odds-match');
+            var selectionSelect = document.getElementById('world-cup-odds-selection-select');
+            var selectionInput = document.getElementById('world-cup-odds-selection-input');
+            var decimalOdds = document.getElementById('world-cup-odds-decimal');
+            var weight = document.getElementById('world-cup-odds-weight');
+            var active = document.getElementById('world-cup-odds-active');
+            var rows = document.querySelectorAll('.world-cup-odds-row');
+            var ajaxResult = document.getElementById('world-cup-odds-ajax-result');
+            var selectedRow = null;
+
+            if (!form || !market || !match || !selectionSelect || !selectionInput || !decimalOdds || !weight || !active) {
+                return;
+            }
+
+            function appendSelectionOption(value, label) {
+                var option = document.createElement('option');
+                option.value = value;
+                option.textContent = label;
+                selectionSelect.appendChild(option);
+            }
+
+            function setSelectValue(select, value) {
+                for (var i = 0; i < select.options.length; i++) {
+                    if (select.options[i].value === value) {
+                        select.selectedIndex = i;
+                        return;
+                    }
+                }
+            }
+
+            function refreshSelectionControl(selectedValue) {
+                var selectedMarket = market.value;
+                var emptyMatchOption = match.options[0];
+
+                if (emptyMatchOption) {
+                    emptyMatchOption.disabled = selectedMarket === '1x2';
+                }
+
+                if (selectedMarket === '1x2' && match.value === '' && match.options.length > 1) {
+                    match.selectedIndex = 1;
+                }
+
+                var selectedMatch = match.options[match.selectedIndex];
+                var stage = selectedMatch ? selectedMatch.getAttribute('data-stage') : '';
+                var homeTeam = selectedMatch ? selectedMatch.getAttribute('data-home-team') : '';
+                var awayTeam = selectedMatch ? selectedMatch.getAttribute('data-away-team') : '';
+
+                if (selectedMarket === 'winner') {
+                    match.value = '';
+                    match.disabled = true;
+                    selectionSelect.classList.add('d-none');
+                    selectionSelect.disabled = true;
+                    selectionInput.classList.remove('d-none');
+                    selectionInput.disabled = false;
+                    selectionInput.value = selectedValue || selectionInput.value;
+                    return;
+                }
+
+                match.disabled = false;
+                selectionInput.classList.add('d-none');
+                selectionInput.disabled = true;
+                selectionSelect.classList.remove('d-none');
+                selectionSelect.disabled = false;
+                selectionSelect.innerHTML = '';
+                appendSelectionOption('home', (homeTeam || '主队') + ' 胜');
+
+                if (stage === 'group') {
+                    appendSelectionOption('draw', '平局');
+                }
+
+                appendSelectionOption('away', (awayTeam || '客队') + ' 胜');
+
+                if (selectedValue) {
+                    setSelectValue(selectionSelect, selectedValue);
+                }
+            }
+
+            function selectionValue() {
+                return market.value === 'winner' ? selectionInput.value : selectionSelect.value;
+            }
+
+            function showAjaxResult(success, message) {
+                if (!ajaxResult) {
+                    return;
+                }
+
+                ajaxResult.className = 'alert ' + (success ? 'alert-success' : 'alert-danger');
+                ajaxResult.textContent = message;
+            }
+
+            function formatNullable(value) {
+                return value === null || value === undefined || value === '' ? '-' : value;
+            }
+
+            function findOddsRow(odds) {
+                if (selectedRow) {
+                    return selectedRow;
+                }
+
+                for (var i = 0; i < rows.length; i++) {
+                    var row = rows[i];
+                    var matchId = odds.match_id === null ? '' : String(odds.match_id);
+
+                    if (row.getAttribute('data-market') === odds.market
+                        && (row.getAttribute('data-match-id') || '') === matchId
+                        && row.getAttribute('data-selection') === odds.selection) {
+                        return row;
+                    }
+                }
+
+                return null;
+            }
+
+            function updateOddsRow(odds) {
+                var row = findOddsRow(odds);
+                if (!row) {
+                    return;
+                }
+
+                var matchId = odds.match_id === null ? '' : String(odds.match_id);
+                row.setAttribute('data-market', odds.market);
+                row.setAttribute('data-match-id', matchId);
+                row.setAttribute('data-selection', odds.selection);
+                row.setAttribute('data-decimal-odds', odds.decimal_odds);
+                row.setAttribute('data-locked-weight', odds.locked_weight);
+                row.setAttribute('data-is-active', odds.is_active);
+
+                row.cells[1].textContent = odds.market;
+                row.cells[2].textContent = formatNullable(odds.match_id);
+                row.cells[3].textContent = odds.team_pair || '-';
+                row.cells[4].textContent = odds.selection_label || odds.selection;
+                row.cells[5].textContent = odds.decimal_odds;
+                row.cells[6].textContent = formatNullable(odds.previous_odds);
+                row.cells[7].textContent = parseInt(odds.is_active, 10) === 1 ? '启用' : '停用';
+                row.cells[8].textContent = odds.locked_weight;
+                row.cells[9].textContent = formatNullable(odds.updated_at);
+                selectedRow = row;
+            }
+
+            function postOddsForm(formData, onSuccess) {
+                var request = new XMLHttpRequest();
+
+                request.open('POST', form.getAttribute('action'));
+                request.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+                request.onload = function () {
+                    var response = {};
+
+                    try {
+                        response = JSON.parse(request.responseText);
+                    } catch (error) {
+                        showAjaxResult(false, '保存失败');
+                        return;
+                    }
+
+                    if (request.status >= 200 && request.status < 300 && parseInt(response.code, 10) === 200) {
+                        onSuccess(response.data || {});
+                        showAjaxResult(true, '保存成功');
+                        return;
+                    }
+
+                    showAjaxResult(false, response.msg || '保存失败');
+                };
+                request.onerror = function () {
+                    showAjaxResult(false, '保存失败');
+                };
+                request.send(formData);
+            }
+
+            function saveInlineOdds(row, value) {
+                var decimalValue = parseFloat(value);
+                if (!decimalValue || decimalValue <= 1) {
+                    showAjaxResult(false, '赔率必须大于 1');
+                    row.cells[5].textContent = row.getAttribute('data-decimal-odds') || '-';
+                    return;
+                }
+
+                selectedRow = row;
+
+                var formData = new FormData();
+                formData.append('_token', form.querySelector('input[name="_token"]').value);
+                formData.append('market', row.getAttribute('data-market'));
+                formData.append('match_id', row.getAttribute('data-match-id') || '');
+                formData.append('selection', row.getAttribute('data-selection'));
+                formData.append('decimal_odds', value);
+                formData.append('is_active', row.getAttribute('data-is-active') || '1');
+                formData.append('locked_weight', row.getAttribute('data-locked-weight') || '0');
+
+                postOddsForm(formData, function (odds) {
+                    updateOddsRow(odds);
+                });
+            }
+
+            function beginInlineOddsEdit(row, cell) {
+                if (cell.querySelector('input')) {
+                    return;
+                }
+
+                var originalValue = row.getAttribute('data-decimal-odds') || cell.textContent.trim();
+                var input = document.createElement('input');
+                input.className = 'form-control form-control-sm';
+                input.value = originalValue;
+                var finished = false;
+                cell.textContent = '';
+                cell.appendChild(input);
+                input.focus();
+                input.select();
+
+                input.addEventListener('click', function (event) {
+                    event.stopPropagation();
+                });
+                input.addEventListener('blur', function () {
+                    if (finished) {
+                        return;
+                    }
+
+                    finished = true;
+                    saveInlineOdds(row, input.value);
+                });
+                input.addEventListener('keydown', function (event) {
+                    if (event.key === 'Enter') {
+                        event.preventDefault();
+                        if (finished) {
+                            return;
+                        }
+
+                        finished = true;
+                        saveInlineOdds(row, input.value);
+                    }
+
+                    if (event.key === 'Escape') {
+                        event.preventDefault();
+                        finished = true;
+                        cell.textContent = originalValue;
+                    }
+                });
+            }
+
+            form.addEventListener('submit', function (event) {
+                event.preventDefault();
+
+                var formData = new FormData(form);
+                formData.set('selection', selectionValue());
+
+                postOddsForm(formData, function (odds) {
+                    updateOddsRow(odds);
+                    decimalOdds.value = odds.decimal_odds || decimalOdds.value;
+                    weight.value = odds.locked_weight || '0';
+                    setSelectValue(active, String(odds.is_active === null ? 1 : odds.is_active));
+                });
+            });
+
+            market.addEventListener('change', function () {
+                refreshSelectionControl('');
+            });
+            match.addEventListener('change', function () {
+                refreshSelectionControl(selectionSelect.value);
+            });
+            refreshSelectionControl('');
+
+            Array.prototype.forEach.call(rows, function (row) {
+                row.addEventListener('click', function () {
+                    selectedRow = row;
+                    var rowMarket = row.getAttribute('data-market');
+                    var rowSelection = row.getAttribute('data-selection') || '';
+
+                    setSelectValue(market, row.getAttribute('data-market'));
+                    setSelectValue(match, row.getAttribute('data-match-id') || '');
+                    refreshSelectionControl(rowSelection);
+
+                    if (rowMarket === 'winner') {
+                        selectionInput.value = rowSelection;
+                    } else {
+                        setSelectValue(selectionSelect, rowSelection);
+                    }
+
+                    decimalOdds.value = row.getAttribute('data-decimal-odds') || '';
+                    weight.value = row.getAttribute('data-locked-weight') || '0';
+                    setSelectValue(active, row.getAttribute('data-is-active') || '1');
+                });
+
+                row.cells[5].addEventListener('click', function (event) {
+                    event.stopPropagation();
+                    beginInlineOddsEdit(row, row.cells[5]);
+                });
+            });
+        })();
+    </script>
+@endsection

+ 308 - 0
resources/views/admin/world_cup/rewards.blade.php

@@ -0,0 +1,308 @@
+@extends('base.base')
+@section('base')
+    <div class="main-panel">
+        <div class="content-wrapper">
+            <div class="page-header">
+                <h3 class="page-title">
+                    <span class="page-title-icon bg-gradient-primary text-white mr-2">
+                        <i class="mdi mdi-trophy"></i>
+                    </span>
+                    World Cup 奖励审核
+                </h3>
+                <nav aria-label="breadcrumb">
+                    <ol class="breadcrumb">
+                        <li class="breadcrumb-item"><a href="#">活动管理</a></li>
+                        <li class="breadcrumb-item active" aria-current="page">奖励审核</li>
+                    </ol>
+                </nav>
+            </div>
+
+            <div class="row">
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-warning text-white">
+                        <div class="card-body">
+                            <h4 class="font-weight-normal mb-3">待审核</h4>
+                            <h2 class="mb-0">{{ $kpi['reviewing_count'] ?? 0 }}</h2>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-success text-white">
+                        <div class="card-body">
+                            <h4 class="font-weight-normal mb-3">已通过</h4>
+                            <h2 class="mb-0">{{ $kpi['approved_count'] ?? 0 }}</h2>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-danger text-white">
+                        <div class="card-body">
+                            <h4 class="font-weight-normal mb-3">已驳回</h4>
+                            <h2 class="mb-0">{{ $kpi['rejected_count'] ?? 0 }}</h2>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-3 stretch-card grid-margin">
+                    <div class="card bg-gradient-info text-white">
+                        <div class="card-body">
+                            <h4 class="font-weight-normal mb-3">已赔付</h4>
+                            <h2 class="mb-0">${{ number_format(($kpi['paid_liability'] ?? 0) / 100, 2) }}</h2>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">审核队列</h4>
+                            <form class="form-inline mb-3" method="get" action="/admin/world-cup/rewards">
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">状态</label>
+                                    <select class="form-control" name="status">
+                                        @foreach(['reviewing' => '待审核', 'approved' => '已通过', 'rejected' => '已驳回', 'on_hold' => '挂起', 'clawed_back' => '已追回', '' => '全部'] as $value => $label)
+                                            <option value="{{ $value }}" @if(($filters['status'] ?? '') === $value) selected @endif>{{ $label }}</option>
+                                        @endforeach
+                                    </select>
+                                </div>
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">风险</label>
+                                    <select class="form-control" name="risk">
+                                        @foreach(['' => '全部', 'low' => 'Low', 'medium' => 'Medium', 'high' => 'High'] as $value => $label)
+                                            <option value="{{ $value }}" @if(($filters['risk'] ?? '') === $value) selected @endif>{{ $label }}</option>
+                                        @endforeach
+                                    </select>
+                                </div>
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">搜索</label>
+                                    <input class="form-control" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="Reward/User ID">
+                                </div>
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">条数</label>
+                                    <input class="form-control" name="limit" value="{{ $filters['limit'] ?? 100 }}" style="width: 90px;">
+                                </div>
+                                <button type="submit" class="btn btn-gradient-primary btn-sm mb-2">查询</button>
+                            </form>
+
+                            <div class="mb-3">
+                                <button type="button" class="btn btn-success btn-sm js-batch" data-action="approve">批量通过</button>
+                                <button type="button" class="btn btn-danger btn-sm js-batch" data-action="reject">批量驳回</button>
+                                <span class="text-muted ml-2">High risk 奖励必须单笔审核,批量通过会被服务端拒绝。</span>
+                            </div>
+
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th><input type="checkbox" class="batch-all"></th>
+                                        <th>Reward ID</th>
+                                        <th>邀请人</th>
+                                        <th>被邀请人</th>
+                                        <th>首充</th>
+                                        <th>各得奖励</th>
+                                        <th>总赔付</th>
+                                        <th>风险</th>
+                                        <th>状态</th>
+                                        <th>提交时间</th>
+                                        <th>审核人</th>
+                                        <th>操作</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($list as $row)
+                                        <tr>
+                                            <td><input type="checkbox" class="td-check reward-check" value="{{ $row['reward_id'] }}"></td>
+                                            <td>{{ $row['reward_id'] }}</td>
+                                            <td>{{ $row['referrer_id'] }}</td>
+                                            <td>{{ $row['invitee_id'] }}</td>
+                                            <td>${{ number_format(($row['first_deposit_amt'] ?? 0) / 100, 2) }}</td>
+                                            <td>${{ number_format(($row['reward_each'] ?? 0) / 100, 2) }}</td>
+                                            <td>${{ number_format(($row['total_liability'] ?? 0) / 100, 2) }}</td>
+                                            <td>
+                                                <span class="badge badge-{{ ($row['risk_level'] ?? '') === 'high' ? 'danger' : (($row['risk_level'] ?? '') === 'medium' ? 'warning' : 'success') }}">
+                                                    {{ $row['risk_level'] ?? '-' }} / {{ $row['risk_score'] ?? 0 }}
+                                                </span>
+                                            </td>
+                                            <td>{{ $row['status'] ?? '-' }}</td>
+                                            <td>{{ $row['submitted_at'] ?? '-' }}</td>
+                                            <td>{{ $row['review_by'] ?? '-' }}</td>
+                                            <td style="min-width: 240px;">
+                                                <button type="button" class="btn btn-outline-info btn-sm js-detail" data-id="{{ $row['reward_id'] }}">详情</button>
+                                                @if(($row['status'] ?? '') === 'reviewing')
+                                                    <button type="button" class="btn btn-success btn-sm js-action" data-action="approve" data-id="{{ $row['reward_id'] }}">通过</button>
+                                                    <button type="button" class="btn btn-danger btn-sm js-action" data-action="reject" data-id="{{ $row['reward_id'] }}">驳回</button>
+                                                    <button type="button" class="btn btn-warning btn-sm js-action" data-action="hold" data-id="{{ $row['reward_id'] }}">挂起</button>
+                                                @elseif(($row['status'] ?? '') === 'approved')
+                                                    <button type="button" class="btn btn-dark btn-sm js-action" data-action="clawback" data-id="{{ $row['reward_id'] }}">追回</button>
+                                                @endif
+                                            </td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="12" class="text-center text-muted">暂无数据</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">最近审核日志</h4>
+                            <div class="table-responsive">
+                                <table class="table table-bordered">
+                                    <thead>
+                                    <tr>
+                                        <th>ID</th>
+                                        <th>Reward ID</th>
+                                        <th>操作人</th>
+                                        <th>动作</th>
+                                        <th>原因</th>
+                                        <th>状态变化</th>
+                                        <th>时间</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @forelse($logs as $log)
+                                        <tr>
+                                            <td>{{ $log['id'] ?? '-' }}</td>
+                                            <td>{{ $log['reward_id'] ?? '-' }}</td>
+                                            <td>{{ $log['actor'] ?? '-' }}</td>
+                                            <td>{{ $log['action'] ?? '-' }}</td>
+                                            <td>{{ $log['reason_code'] ?? '-' }}</td>
+                                            <td>{{ $log['before_status'] ?? '-' }} -> {{ $log['after_status'] ?? '-' }}</td>
+                                            <td>{{ $log['created_at'] ?? '-' }}</td>
+                                        </tr>
+                                    @empty
+                                        <tr>
+                                            <td colspan="7" class="text-center text-muted">暂无日志</td>
+                                        </tr>
+                                    @endforelse
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        (function () {
+            function showMessage(message) {
+                if (window.layer) {
+                    layer.msg(message);
+                    return;
+                }
+
+                alert(message);
+            }
+
+            function reloadWhenSuccess(res) {
+                showMessage(res.msg || 'success');
+                if (res.code === 200) {
+                    window.location.reload();
+                }
+            }
+
+            function postReview(url, data) {
+                myRequest(url, 'post', data || {}, reloadWhenSuccess);
+            }
+
+            function requireReason(title) {
+                return window.prompt(title || '请输入原因代码,例如 same_device / bulk_signup / fraud');
+            }
+
+            $('.js-action').on('click', function () {
+                var id = $(this).data('id');
+                var action = $(this).data('action');
+                var data = {};
+
+                if (action === 'reject') {
+                    data.reason_code = requireReason('请输入驳回原因代码');
+                    if (!data.reason_code) {
+                        return;
+                    }
+                }
+
+                if (action === 'clawback') {
+                    data.reason_code = requireReason('请输入追回原因代码');
+                    if (!data.reason_code) {
+                        return;
+                    }
+
+                    data.ban_users = window.confirm('是否同时封禁邀请人与被邀请人?') ? 1 : 0;
+                }
+
+                if (action === 'approve' && !window.confirm('确认通过并立即给双方发放奖励?')) {
+                    return;
+                }
+
+                postReview('/admin/world-cup/rewards/' + id + '/' + action, data);
+            });
+
+            $('.js-batch').on('click', function () {
+                var action = $(this).data('action');
+                var ids = $('.reward-check:checked').map(function () {
+                    return $(this).val();
+                }).get();
+                var data = {
+                    action: action,
+                    reward_ids: ids
+                };
+
+                if (ids.length === 0) {
+                    showMessage('请先选择奖励单');
+                    return;
+                }
+
+                if (action === 'reject') {
+                    data.reason_code = requireReason('请输入批量驳回原因代码');
+                    if (!data.reason_code) {
+                        return;
+                    }
+                }
+
+                if (action === 'approve' && !window.confirm('确认批量通过?High risk 会被服务端拒绝。')) {
+                    return;
+                }
+
+                postReview('/admin/world-cup/rewards/batch', data);
+            });
+
+            $('.js-detail').on('click', function () {
+                var id = $(this).data('id');
+                myRequest('/admin/world-cup/rewards/' + id, 'get', {}, function (res) {
+                    if (res.code !== 200) {
+                        showMessage(res.msg || '查询失败');
+                        return;
+                    }
+
+                    var content = '<pre style="white-space: pre-wrap; padding: 12px;">'
+                        + $('<div/>').text(JSON.stringify(res.data.reward, null, 2)).html()
+                        + '</pre>';
+
+                    if (window.layer) {
+                        layer.open({
+                            type: 1,
+                            area: ['720px', '620px'],
+                            title: '奖励详情 #' + id,
+                            content: content
+                        });
+                        return;
+                    }
+
+                    alert(JSON.stringify(res.data.reward, null, 2));
+                });
+            });
+        })();
+    </script>
+@endsection

+ 228 - 0
resources/views/admin/world_cup/schedule.blade.php

@@ -0,0 +1,228 @@
+@extends('base.base')
+@section('base')
+    <style>
+        .schedule-source-list a {
+            margin-right: 14px;
+            white-space: nowrap;
+        }
+
+        .schedule-table input,
+        .schedule-table select {
+            min-width: 110px;
+        }
+
+        .schedule-table .team-input {
+            min-width: 150px;
+        }
+
+        .schedule-table .venue-input {
+            min-width: 135px;
+        }
+    </style>
+
+    <div class="main-panel">
+        <div class="content-wrapper">
+            <div class="page-header">
+                <h3 class="page-title">
+                    <span class="page-title-icon bg-gradient-primary text-white mr-2">
+                        <i class="mdi mdi-calendar-clock"></i>
+                    </span>
+                    World Cup 赛程维护
+                </h3>
+                <nav aria-label="breadcrumb">
+                    <ol class="breadcrumb">
+                        <li class="breadcrumb-item"><a href="#">活动管理</a></li>
+                        <li class="breadcrumb-item active" aria-current="page">赛程维护</li>
+                    </ol>
+                </nav>
+            </div>
+
+            @if($result)
+                <div class="row">
+                    <div class="col-lg-12 grid-margin stretch-card">
+                        <div class="card">
+                            <div class="card-body">
+                                <h4 class="card-title">更新结果</h4>
+                                <p class="{{ $result['success'] ? 'text-success' : 'text-danger' }}">
+                                    {{ $result['message'] ?? '-' }}
+                                </p>
+                                <div class="table-responsive">
+                                    <table class="table table-bordered">
+                                        <tbody>
+                                        <tr>
+                                            <th>已更新</th>
+                                            <td>{{ $result['data']['updated'] ?? 0 }}</td>
+                                        </tr>
+                                        <tr>
+                                            <th>已跳过</th>
+                                            <td>{{ $result['data']['skipped'] ?? 0 }}</td>
+                                        </tr>
+                                        <tr>
+                                            <th>错误</th>
+                                            <td>
+                                                @forelse(($result['data']['errors'] ?? []) as $error)
+                                                    <div>Row {{ $error['row'] ?? '-' }}: {{ $error['message'] ?? '-' }}</div>
+                                                @empty
+                                                    -
+                                                @endforelse
+                                            </td>
+                                        </tr>
+                                        </tbody>
+                                    </table>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            @endif
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">赛程来源</h4>
+                            <div class="schedule-source-list">
+                                @foreach($scheduleUrls as $label => $url)
+                                    <a href="{{ $url }}" target="_blank">{{ $label }}</a>
+                                @endforeach
+                            </div>
+                            <p class="text-muted mt-3 mb-0">
+                                页面默认列出所有赛程。运营核对来源后,勾选需要修改的比赛行,再提交保存。
+                            </p>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">筛选</h4>
+                            <form class="form-inline" method="get" action="/admin/world-cup/schedule">
+                                <div class="form-group mr-2 mb-2">
+                                    <label class="mr-2">比赛日期</label>
+                                    <input class="form-control" type="date" name="schedule_date" value="{{ $scheduleDate }}">
+                                </div>
+                                <button type="submit" class="btn btn-gradient-primary btn-sm mb-2">按日期查看</button>
+                                <a class="btn btn-outline-secondary btn-sm mb-2 ml-2" href="/admin/world-cup/schedule">查看全部</a>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">CSV 导入比赛</h4>
+                            <form class="form-inline"
+                                  method="post"
+                                  action="/admin/world-cup/schedule/import"
+                                  enctype="multipart/form-data">
+                                @csrf
+                                <input type="hidden" name="schedule_date" value="{{ $scheduleDate }}">
+                                <input class="form-control mr-2 mb-2" type="file" name="csv_file" accept=".csv">
+                                <button type="submit" class="btn btn-gradient-primary btn-sm mb-2">导入</button>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">赛程表单</h4>
+                            <form method="post" action="/admin/world-cup/schedule/update">
+                                @csrf
+                                <input type="hidden" name="schedule_date" value="{{ $scheduleDate }}">
+                                <div class="mb-3">
+                                    <button type="submit" class="btn btn-gradient-primary btn-sm">保存勾选行</button>
+                                    <button type="button" class="btn btn-outline-secondary btn-sm js-check-unresolved">勾选未确定对手</button>
+                                    <button type="button" class="btn btn-outline-secondary btn-sm js-uncheck-all">取消全选</button>
+                                    <a class="btn btn-outline-secondary btn-sm" href="/admin/world-cup/kpi">返回 KPI</a>
+                                </div>
+                                <div class="table-responsive">
+                                    <table class="table table-bordered table-hover schedule-table">
+                                        <thead>
+                                        <tr>
+                                            <th>更新</th>
+                                            <th>Match No</th>
+                                            <th>阶段</th>
+                                            <th>小组</th>
+                                            <th>主队</th>
+                                            <th>客队</th>
+                                            <th>场地</th>
+                                            <th>开赛时间</th>
+                                            <th>状态</th>
+                                        </tr>
+                                        </thead>
+                                        <tbody>
+                                        @forelse($matches as $index => $match)
+                                            @php
+                                                $isUnresolved = strpos((string)($match['home_team'] ?? ''), 'Winner ') === 0
+                                                    || strpos((string)($match['away_team'] ?? ''), 'Winner ') === 0;
+                                            @endphp
+                                            <tr data-unresolved="{{ $isUnresolved ? 1 : 0 }}">
+                                                <td>
+                                                    <input type="checkbox" class="row-enabled" name="matches[{{ $index }}][enabled]" value="1">
+                                                </td>
+                                                <td>
+                                                    {{ $match['match_no'] ?? $match['match_id'] }}
+                                                    <input type="hidden" name="matches[{{ $index }}][match_no]" value="{{ $match['match_no'] ?? $match['match_id'] }}">
+                                                </td>
+                                                <td>{{ $match['stage'] ?? '-' }}</td>
+                                                <td>{{ $match['group_name'] ?? '-' }}</td>
+                                                <td>
+                                                    <input class="form-control team-input" name="matches[{{ $index }}][home_team]" value="{{ $match['home_team'] ?? '' }}">
+                                                </td>
+                                                <td>
+                                                    <input class="form-control team-input" name="matches[{{ $index }}][away_team]" value="{{ $match['away_team'] ?? '' }}">
+                                                </td>
+                                                <td>
+                                                    <input class="form-control venue-input" name="matches[{{ $index }}][venue]" value="{{ $match['venue'] ?? '' }}">
+                                                </td>
+                                                <td>
+                                                    <input class="form-control" name="matches[{{ $index }}][kickoff_at]" value="{{ $match['kickoff_at'] ?? '' }}">
+                                                </td>
+                                                <td>
+                                                    <select class="form-control" name="matches[{{ $index }}][status]">
+                                                        @foreach(['closed' => 'closed', 'scheduled' => 'scheduled', 'finished' => 'finished'] as $value => $label)
+                                                            <option value="{{ $value }}" @if(($match['status'] ?? '') === $value) selected @endif>{{ $label }}</option>
+                                                        @endforeach
+                                                    </select>
+                                                </td>
+                                            </tr>
+                                        @empty
+                                            <tr>
+                                                <td colspan="9" class="text-center text-muted">暂无赛程</td>
+                                            </tr>
+                                        @endforelse
+                                        </tbody>
+                                    </table>
+                                </div>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        (function () {
+            $('.js-check-unresolved').on('click', function () {
+                $('.schedule-table tbody tr').each(function () {
+                    $(this).find('.row-enabled').prop('checked', $(this).data('unresolved') === 1);
+                });
+            });
+
+            $('.js-uncheck-all').on('click', function () {
+                $('.row-enabled').prop('checked', false);
+            });
+        })();
+    </script>
+@endsection

+ 175 - 0
resources/views/admin/world_cup/settlement.blade.php

@@ -0,0 +1,175 @@
+@extends('base.base')
+@section('base')
+    <div class="main-panel">
+        <div class="content-wrapper">
+            <div class="page-header">
+                <h3 class="page-title">
+                    <span class="page-title-icon bg-gradient-primary text-white mr-2">
+                        <i class="mdi mdi-check-circle-outline"></i>
+                    </span>
+                    World Cup 比赛结算
+                </h3>
+                <nav aria-label="breadcrumb">
+                    <ol class="breadcrumb">
+                        <li class="breadcrumb-item"><a href="#">活动管理</a></li>
+                        <li class="breadcrumb-item active" aria-current="page">比赛结算</li>
+                    </ol>
+                </nav>
+            </div>
+
+            @if($result)
+                <div class="alert {{ $result['success'] ? 'alert-success' : 'alert-danger' }}">
+                    <div>{{ $result['success'] ? '结算成功' : ($result['message'] ?? '结算失败') }}</div>
+                    @if(!empty($result['data']))
+                        <div>
+                            赢单 {{ $result['data']['won_count'] ?? 0 }},
+                            输单 {{ $result['data']['lost_count'] ?? 0 }},
+                            派奖 {{ number_format(($result['data']['paid_amount'] ?? 0) / 100, 2) }}
+                        </div>
+                    @endif
+                </div>
+            @endif
+
+            <div class="row">
+                <div class="col-lg-8 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">单场 1X2 结算</h4>
+                            <form class="js-world-cup-settlement-form"
+                                  method="post"
+                                  action="/admin/world-cup/settlement/match"
+                                  data-confirm-message="确认要结算吗?单场结算会更新比赛结果并派发赢单奖励。">
+                                {{ csrf_field() }}
+                                <div class="form-group">
+                                    <label>比赛</label>
+                                    <select class="form-control" id="world-cup-settlement-match" name="match_id">
+                                        @foreach($matches as $match)
+                                            <option value="{{ $match['match_id'] }}"
+                                                data-stage="{{ $match['stage'] }}"
+                                                data-home-team="{{ $match['home_team'] }}"
+                                                data-away-team="{{ $match['away_team'] }}">
+                                                #{{ $match['match_no'] }} {{ $match['home_team'] }} vs {{ $match['away_team'] }}
+                                                / {{ $match['kickoff_at'] }} / {{ $match['status'] }}
+                                            </option>
+                                        @endforeach
+                                    </select>
+                                </div>
+                                <div class="form-group">
+                                    <label>赛果</label>
+                                    <select class="form-control" id="world-cup-settlement-result" name="result">
+                                        <option value="home">主胜 home</option>
+                                        <option value="draw">平局 draw</option>
+                                        <option value="away">客胜 away</option>
+                                    </select>
+                                </div>
+                                <button type="submit" class="btn btn-gradient-primary btn-sm">确认结算单场</button>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="col-lg-4 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">Winner 夺冠盘结算</h4>
+                            <form class="js-world-cup-settlement-form"
+                                  method="post"
+                                  action="/admin/world-cup/settlement/winner"
+                                  data-confirm-message="确认要结算吗?Winner 结算会结算所有待处理夺冠盘。">
+                                {{ csrf_field() }}
+                                <div class="form-group">
+                                    <label>冠军球队</label>
+                                    <input class="form-control" name="selection" placeholder="Brazil">
+                                </div>
+                                <button type="submit" class="btn btn-gradient-primary btn-sm">确认结算 Winner</button>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-lg-12 grid-margin stretch-card">
+                    <div class="card">
+                        <div class="card-body">
+                            <h4 class="card-title">赛程状态</h4>
+                            <div class="table-responsive">
+                                <table class="table table-bordered table-hover">
+                                    <thead>
+                                    <tr>
+                                        <th>Match</th>
+                                        <th>阶段</th>
+                                        <th>对阵</th>
+                                        <th>开赛</th>
+                                        <th>状态</th>
+                                        <th>结果</th>
+                                    </tr>
+                                    </thead>
+                                    <tbody>
+                                    @foreach($matches as $match)
+                                        <tr>
+                                            <td>#{{ $match['match_no'] }}</td>
+                                            <td>{{ $match['stage'] }}</td>
+                                            <td>{{ $match['home_team'] }} vs {{ $match['away_team'] }}</td>
+                                            <td>{{ $match['kickoff_at'] }}</td>
+                                            <td>{{ $match['status'] }}</td>
+                                            <td>{{ $match['result'] ?? '-' }}</td>
+                                        </tr>
+                                    @endforeach
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <script>
+        (function () {
+            var matchSelect = document.getElementById('world-cup-settlement-match');
+            var resultSelect = document.getElementById('world-cup-settlement-result');
+            var settlementForms = document.querySelectorAll('.js-world-cup-settlement-form');
+
+            if (!matchSelect || !resultSelect) {
+                return;
+            }
+
+            function appendOption(value, label) {
+                var option = document.createElement('option');
+                option.value = value;
+                option.textContent = label;
+                resultSelect.appendChild(option);
+            }
+
+            function refreshResultOptions() {
+                var selected = matchSelect.options[matchSelect.selectedIndex];
+                var stage = selected.getAttribute('data-stage');
+                var homeTeam = selected.getAttribute('data-home-team') || 'Home';
+                var awayTeam = selected.getAttribute('data-away-team') || 'Away';
+
+                resultSelect.innerHTML = '';
+                appendOption('home', '主胜 ' + homeTeam);
+
+                if (stage === 'group') {
+                    appendOption('draw', '平局');
+                }
+
+                appendOption('away', '客胜 ' + awayTeam);
+            }
+
+            matchSelect.addEventListener('change', refreshResultOptions);
+            refreshResultOptions();
+
+            Array.prototype.forEach.call(settlementForms, function (form) {
+                form.addEventListener('submit', function (event) {
+                    var message = form.getAttribute('data-confirm-message') || '确认要结算吗?';
+
+                    if (!window.confirm(message)) {
+                        event.preventDefault();
+                    }
+                });
+            });
+        })();
+    </script>
+@endsection

+ 12 - 0
routes/game.php

@@ -323,6 +323,18 @@ Route::group([
     $route->any('/superball/lucky-numbers', 'Game\SuperballActivityController@luckyNumbers');
     $route->any('/superball/claim-yesterday-reward', 'Game\SuperballActivityController@claimYesterdayReward');
 
+    // World Cup activity
+    $route->any('/world-cup/info', 'Game\WorldCupActivityController@info');
+    $route->any('/world-cup/invite/bind', 'Game\WorldCupActivityController@bindInvite');
+    $route->any('/world-cup/invite/log', 'Game\WorldCupActivityController@inviteLog');
+    $route->any('/world-cup/reward/log', 'Game\WorldCupActivityController@rewardLog');
+    $route->any('/world-cup/matches', 'Game\WorldCupActivityController@matches');
+    $route->any('/world-cup/winner-markets', 'Game\WorldCupActivityController@winnerMarkets');
+    $route->any('/world-cup/matches/{matchId}/favorite', 'Game\WorldCupActivityController@toggleFavorite');
+    $route->any('/world-cup/bet-panel', 'Game\WorldCupActivityController@betPanelState');
+    $route->any('/world-cup/bet/log', 'Game\WorldCupActivityController@betLog');
+    $route->any('/world-cup/bets', 'Game\WorldCupActivityController@placeBet');
+
     $route->any('/mail/check', 'Game\MailController@newMsg');
     $route->any('/mail/list', 'Game\MailController@mailList');
     $route->any('/mail/del', 'Game\MailController@delete');

+ 24 - 0
routes/web.php

@@ -210,6 +210,30 @@ Route::group([
         $route->get('/superball/daily', 'Admin\SuperballController@daily')->defaults('name', 'Superball 每日数据');
         $route->get('/superball/user-tasks', 'Admin\SuperballController@userTasks')->defaults('name', 'Superball 用户任务');
         $route->get('/superball/prizes', 'Admin\SuperballController@prizes')->defaults('name', 'Superball 奖励记录');
+        // World Cup reward review
+        $route->get('/world-cup/rewards', 'Admin\WorldCupReviewController@rewards')->defaults('name', 'World Cup 奖励审核队列');
+        $route->get('/world-cup/rewards/{rewardId}', 'Admin\WorldCupReviewController@reward')->defaults('name', 'World Cup 奖励详情');
+        $route->post('/world-cup/rewards/{rewardId}/approve', 'Admin\WorldCupReviewController@approve')->defaults('name', 'World Cup 奖励通过');
+        $route->post('/world-cup/rewards/{rewardId}/reject', 'Admin\WorldCupReviewController@reject')->defaults('name', 'World Cup 奖励驳回');
+        $route->post('/world-cup/rewards/{rewardId}/hold', 'Admin\WorldCupReviewController@hold')->defaults('name', 'World Cup 奖励挂起');
+        $route->post('/world-cup/rewards/{rewardId}/clawback', 'Admin\WorldCupReviewController@clawback')->defaults('name', 'World Cup 奖励追回');
+        $route->post('/world-cup/rewards/batch', 'Admin\WorldCupReviewController@batch')->defaults('name', 'World Cup 奖励批量审核');
+        $route->get('/world-cup/kpi', 'Admin\WorldCupReviewController@kpi')->defaults('name', 'World Cup 审核看板');
+        $route->get('/world-cup/logs', 'Admin\WorldCupReviewController@logs')->defaults('name', 'World Cup 审核日志');
+
+        $route->get('/world-cup/schedule', 'Admin\WorldCupScheduleController@index')->defaults('name', 'World Cup 赛程更新');
+        $route->post('/world-cup/schedule/update', 'Admin\WorldCupScheduleController@update')->defaults('name', 'World Cup 更新未确定赛程');
+        $route->post('/world-cup/schedule/import', 'Admin\WorldCupScheduleController@import')
+            ->defaults('name', 'World Cup 导入比赛');
+        $route->get('/world-cup/odds', 'Admin\WorldCupMarketController@odds')->defaults('name', 'World Cup 盘口维护');
+        $route->post('/world-cup/odds', 'Admin\WorldCupMarketController@saveOdds')->defaults('name', 'World Cup 保存盘口');
+        $route->post('/world-cup/odds/import', 'Admin\WorldCupMarketController@importOdds')
+            ->defaults('name', 'World Cup 导入赔率');
+        $route->get('/world-cup/bets', 'Admin\WorldCupMarketController@bets')->defaults('name', 'World Cup 下注日志');
+        $route->get('/world-cup/settlement', 'Admin\WorldCupMarketController@settlement')->defaults('name', 'World Cup 比赛结算');
+        $route->post('/world-cup/settlement/match', 'Admin\WorldCupMarketController@settleMatch')->defaults('name', 'World Cup 单场结算');
+        $route->post('/world-cup/settlement/winner', 'Admin\WorldCupMarketController@settleWinner')->defaults('name', 'World Cup 夺冠盘结算');
+
         $route->get('/exchange/code_list', 'Admin\ExchangeController@exchangeCodeList');
         $route->get('/gift/add', 'Admin\ExchangeController@giftAddView');
         $route->get('/gift/list', 'Admin\ExchangeController@giftList');

+ 71 - 0
tests/Unit/GenerateWorldCupReferralRewardOnOrderPaidTest.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Events\OrderPaid;
+use App\Listeners\GenerateWorldCupReferralRewardOnOrderPaid;
+use App\Services\WorldCup\WorldCupReferralRewardService;
+use Illuminate\Support\Facades\Log;
+use Tests\TestCase;
+
+class GenerateWorldCupReferralRewardOnOrderPaidTest extends TestCase
+{
+    public function test_listener_delegates_order_paid_to_referral_reward_service()
+    {
+        $service = new SpyWorldCupReferralRewardService();
+        $listener = new GenerateWorldCupReferralRewardOnOrderPaid($service);
+
+        $listener->handle(new OrderPaid(1002, 120.0, 'order-120'));
+
+        $this->assertSame([
+            'user_id' => 1002,
+            'pay_amt' => 120.0,
+            'order_sn' => 'order-120',
+        ], $service->handled[0]);
+    }
+
+    public function test_listener_logs_exception_without_throwing()
+    {
+        Log::shouldReceive('error')
+            ->once()
+            ->with('GenerateWorldCupReferralRewardOnOrderPaid: exception', \Mockery::type('array'));
+
+        $listener = new GenerateWorldCupReferralRewardOnOrderPaid(new ThrowingWorldCupReferralRewardService());
+
+        $listener->handle(new OrderPaid(1002, 120.0, 'order-120'));
+
+        $this->assertTrue(true);
+    }
+}
+
+class SpyWorldCupReferralRewardService extends WorldCupReferralRewardService
+{
+    public $handled = [];
+
+    public function __construct()
+    {
+    }
+
+    public function handleFirstDeposit(int $userId, float $payAmt, string $orderSn): array
+    {
+        $this->handled[] = [
+            'user_id' => $userId,
+            'pay_amt' => $payAmt,
+            'order_sn' => $orderSn,
+        ];
+
+        return ['success' => true, 'status' => 'created'];
+    }
+}
+
+class ThrowingWorldCupReferralRewardService extends WorldCupReferralRewardService
+{
+    public function __construct()
+    {
+    }
+
+    public function handleFirstDeposit(int $userId, float $payAmt, string $orderSn): array
+    {
+        throw new \RuntimeException('boom');
+    }
+}

+ 470 - 0
tests/Unit/WorldCupBetServiceTest.php

@@ -0,0 +1,470 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\WorldCupBetRepositoryInterface;
+use App\Services\WorldCup\WorldCupBetService;
+use Carbon\Carbon;
+use Tests\TestCase;
+
+class WorldCupBetServiceTest extends TestCase
+{
+    public function test_low_balance_can_open_bet_panel_but_button_shows_min_bet()
+    {
+        $service = new WorldCupBetService();
+
+        $state = $service->buildBetPanelState(400, false);
+
+        $this->assertTrue($state['can_open']);
+        $this->assertTrue($state['confirm_disabled']);
+        $this->assertSame(400, $state['default_stake']);
+        $this->assertSame('Min bet $5', $state['button_text']);
+    }
+
+    public function test_bet_rejects_when_balance_is_below_minimum_stake()
+    {
+        $service = new WorldCupBetService();
+
+        $result = $service->validateStake(400, 400, false);
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('Min bet $5', $result['message']);
+    }
+
+    public function test_bet_rejects_stake_below_minimum_even_when_balance_is_enough()
+    {
+        $service = new WorldCupBetService();
+
+        $result = $service->validateStake(400, 10000, false);
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('Min bet $5', $result['message']);
+    }
+
+    public function test_first_bet_stake_is_capped_at_ten_dollars()
+    {
+        $service = new WorldCupBetService();
+
+        $result = $service->validateStake(1100, 10000, false);
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('First bet $5–$10 · +50% bonus · whole numbers only', $result['message']);
+    }
+
+    public function test_place_bet_writes_pending_bet_deducts_balance_and_locks_odds()
+    {
+        $repository = new InMemoryWorldCupBetRepository();
+        $service = new WorldCupBetService($repository);
+
+        $result = $service->placeBet([
+            'user_id' => 1001,
+            'game_id' => 90000001,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'stake' => 1000,
+            'idempotency_key' => 'bet-key-1',
+        ], Carbon::parse('2026-06-05 12:00:00'));
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('created', $result['status']);
+        $this->assertSame(9000, $repository->balances[1001]);
+        $this->assertSame(1000, $repository->bets[0]['stake']);
+        $this->assertSame(2.15, $repository->bets[0]['odds']);
+        $this->assertSame(575, $repository->bets[0]['bonus_amount']);
+        $this->assertSame(2725, $repository->bets[0]['potential_payout']);
+        $this->assertTrue($repository->states[1001]['first_bet_used']);
+    }
+
+    public function test_place_bet_is_idempotent_by_user_and_key()
+    {
+        $repository = new InMemoryWorldCupBetRepository();
+        $service = new WorldCupBetService($repository);
+
+        $payload = [
+            'user_id' => 1001,
+            'game_id' => 90000001,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'stake' => 1000,
+            'idempotency_key' => 'bet-key-1',
+        ];
+
+        $first = $service->placeBet($payload, Carbon::parse('2026-06-05 12:00:00'));
+        $second = $service->placeBet($payload, Carbon::parse('2026-06-05 12:00:00'));
+
+        $this->assertSame('created', $first['status']);
+        $this->assertSame('exists', $second['status']);
+        $this->assertSame(9000, $repository->balances[1001]);
+        $this->assertCount(1, $repository->bets);
+    }
+
+    public function test_place_bet_rejects_inactive_odds_and_closed_match()
+    {
+        $repository = new InMemoryWorldCupBetRepository();
+        $service = new WorldCupBetService($repository);
+
+        $inactiveOdds = $service->placeBet([
+            'user_id' => 1001,
+            'game_id' => 90000001,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'away',
+            'stake' => 1000,
+            'idempotency_key' => 'bet-key-1',
+        ], Carbon::parse('2026-06-05 12:00:00'));
+
+        $closedMatch = $service->placeBet([
+            'user_id' => 1001,
+            'game_id' => 90000001,
+            'market' => '1x2',
+            'match_id' => 11,
+            'selection' => 'home',
+            'stake' => 1000,
+            'idempotency_key' => 'bet-key-2',
+        ], Carbon::parse('2026-06-05 12:00:00'));
+
+        $this->assertFalse($inactiveOdds['success']);
+        $this->assertSame('Odds are not available', $inactiveOdds['message']);
+        $this->assertFalse($closedMatch['success']);
+        $this->assertSame('Match is not available', $closedMatch['message']);
+    }
+
+    public function test_place_bet_rejects_match_inside_one_hour_cutoff()
+    {
+        $repository = new InMemoryWorldCupBetRepository();
+        $service = new WorldCupBetService($repository);
+
+        $result = $service->placeBet([
+            'user_id' => 1001,
+            'game_id' => 90000001,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'stake' => 1000,
+            'idempotency_key' => 'bet-key-cutoff',
+        ], Carbon::parse('2026-06-05 14:00:00'));
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('Match is not available', $result['message']);
+    }
+
+    public function test_bet_log_returns_stats_and_all_rows()
+    {
+        $repository = new InMemoryWorldCupBetRepository();
+        $repository->seedBetLogs();
+        $service = new WorldCupBetService($repository);
+
+        $result = $service->betLog(1001, 'all', 20);
+
+        $this->assertSame(2, $result['stats']['pending_count']);
+        $this->assertSame(2600, $result['stats']['total_won']);
+        $this->assertCount(4, $result['list']);
+        $this->assertSame('in_play', $result['list'][0]['display_status']);
+        $this->assertSame(106800, $result['list'][0]['display_payout']);
+        $this->assertSame('won', $result['list'][2]['display_status']);
+        $this->assertSame('lost', $result['list'][3]['display_status']);
+        $this->assertSame(-500, $result['list'][3]['display_payout']);
+    }
+
+    public function test_bet_log_filters_pending_settled_and_won()
+    {
+        $repository = new InMemoryWorldCupBetRepository();
+        $repository->seedBetLogs();
+        $service = new WorldCupBetService($repository);
+
+        $pending = $service->betLog(1001, 'pending', 20);
+        $settled = $service->betLog(1001, 'settled', 20);
+        $won = $service->betLog(1001, 'won', 20);
+
+        $this->assertCount(2, $pending['list']);
+        $this->assertCount(2, $settled['list']);
+        $this->assertCount(1, $won['list']);
+        $this->assertSame('won', $won['list'][0]['display_status']);
+    }
+
+    public function test_admin_bet_logs_returns_formatted_rows_for_backoffice()
+    {
+        $repository = new InMemoryWorldCupBetRepository();
+        $repository->seedBetLogs();
+        $service = new WorldCupBetService($repository);
+
+        $result = $service->adminBetLogs([
+            'game_id' => '90000001',
+            'market' => 'winner',
+            'status' => 'pending',
+            'limit' => 20,
+        ]);
+
+        $this->assertCount(1, $result['list']);
+        $this->assertSame(90000001, $result['list'][0]['game_id']);
+        $this->assertSame(1001, $result['list'][0]['user_id']);
+        $this->assertSame('$178.00', $result['list'][0]['stake_text']);
+        $this->assertSame('$0.00', $result['list'][0]['bonus_text']);
+        $this->assertSame('Pending', $result['list'][0]['status_label']);
+        $this->assertSame('/admin/global/id_find?UserID=1001', $result['list'][0]['user_detail_url']);
+
+        $lost = $service->adminBetLogs([
+            'game_id' => '90000003',
+            'status' => 'lost',
+            'limit' => 20,
+        ]);
+
+        $this->assertSame('20 (South Korea vs Czechia)', $lost['list'][0]['match_label']);
+        $this->assertSame('$0.00', $lost['list'][0]['potential_payout_text']);
+    }
+}
+
+class InMemoryWorldCupBetRepository implements WorldCupBetRepositoryInterface
+{
+    public $balances = [1001 => 10000];
+
+    public $states = [];
+
+    public $bets = [];
+
+    private $matches = [
+        10 => [
+            'match_id' => 10,
+            'kickoff_at' => '2026-06-05 15:00:00',
+            'status' => 'scheduled',
+            'result' => null,
+        ],
+        11 => [
+            'match_id' => 11,
+            'kickoff_at' => '2026-06-05 12:30:00',
+            'status' => 'scheduled',
+            'result' => null,
+        ],
+    ];
+
+    private $logMatches = [
+        20 => [
+            'match_id' => 20,
+            'home_team' => 'South Korea',
+            'away_team' => 'Czechia',
+            'competition' => 'World Cup',
+        ],
+    ];
+
+    private $odds = [
+        '1x2:10:home' => [
+            'odds_id' => 1,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'decimal_odds' => 2.15,
+            'is_active' => 1,
+        ],
+        '1x2:10:away' => [
+            'odds_id' => 2,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'away',
+            'decimal_odds' => 1.95,
+            'is_active' => 0,
+        ],
+    ];
+
+    public function findBetByIdempotencyKey(int $userId, string $idempotencyKey): ?array
+    {
+        foreach ($this->bets as $bet) {
+            if ((int)$bet['user_id'] === $userId && $bet['idempotency_key'] === $idempotencyKey) {
+                return $bet;
+            }
+        }
+
+        return null;
+    }
+
+    public function findMatch(int $matchId): ?array
+    {
+        return $this->matches[$matchId] ?? null;
+    }
+
+    public function findActiveOdds(string $market, ?int $matchId, string $selection): ?array
+    {
+        $key = $market . ':' . (string)$matchId . ':' . $selection;
+        $odds = $this->odds[$key] ?? null;
+
+        if (!$odds || (int)$odds['is_active'] !== 1) {
+            return null;
+        }
+
+        return $odds;
+    }
+
+    public function getBalance(int $userId): int
+    {
+        return (int)($this->balances[$userId] ?? 0);
+    }
+
+    public function isFirstBetUsed(int $userId): bool
+    {
+        return (bool)($this->states[$userId]['first_bet_used'] ?? false);
+    }
+
+    public function createBetAndDeductBalance(array $bet, bool $markFirstBetUsed): array
+    {
+        $this->balances[$bet['user_id']] -= $bet['stake'];
+        if ($markFirstBetUsed) {
+            $this->states[$bet['user_id']]['first_bet_used'] = true;
+        }
+
+        $bet['bet_id'] = count($this->bets) + 1;
+        $this->bets[] = $bet;
+
+        return $bet;
+    }
+
+    public function seedBetLogs(): void
+    {
+        $this->bets = [
+            [
+                'bet_id' => 1,
+                'user_id' => 1001,
+                'game_id' => 90000001,
+                'market' => 'winner',
+                'match_id' => null,
+                'selection' => 'France',
+                'stake' => 17800,
+                'odds' => 6.0,
+                'bonus_amount' => 0,
+                'potential_payout' => 106800,
+                'status' => 'pending',
+                'created_at' => '2026-06-06 12:00:00',
+            ],
+            [
+                'bet_id' => 2,
+                'user_id' => 1001,
+                'game_id' => 90000001,
+                'market' => '1x2',
+                'match_id' => 20,
+                'selection' => 'home',
+                'stake' => 1000,
+                'odds' => 2.6,
+                'bonus_amount' => 800,
+                'potential_payout' => 2600,
+                'status' => 'pending',
+                'created_at' => '2026-06-06 11:00:00',
+            ],
+            [
+                'bet_id' => 3,
+                'user_id' => 1001,
+                'game_id' => 90000002,
+                'market' => '1x2',
+                'match_id' => 20,
+                'selection' => 'draw',
+                'stake' => 1000,
+                'odds' => 2.6,
+                'bonus_amount' => 0,
+                'potential_payout' => 2600,
+                'status' => 'won',
+                'created_at' => '2026-06-06 10:00:00',
+            ],
+            [
+                'bet_id' => 4,
+                'user_id' => 1001,
+                'game_id' => 90000003,
+                'market' => '1x2',
+                'match_id' => 20,
+                'selection' => 'draw',
+                'stake' => 500,
+                'odds' => 2.6,
+                'bonus_amount' => 0,
+                'potential_payout' => 1300,
+                'status' => 'lost',
+                'created_at' => '2026-06-06 09:00:00',
+            ],
+        ];
+    }
+
+    public function betLogs(int $userId, string $status, int $limit): array
+    {
+        $rows = array_values(array_filter($this->bets, function (array $bet) use ($userId, $status) {
+            if ((int)$bet['user_id'] !== $userId) {
+                return false;
+            }
+
+            if ($status === 'pending') {
+                return $bet['status'] === 'pending';
+            }
+
+            if ($status === 'settled') {
+                return in_array($bet['status'], ['won', 'lost'], true);
+            }
+
+            if ($status === 'won') {
+                return $bet['status'] === 'won';
+            }
+
+            return true;
+        }));
+
+        return array_slice(array_map(function (array $bet) {
+            $match = $bet['match_id'] ? ($this->logMatches[$bet['match_id']] ?? []) : [];
+
+            return array_merge($bet, [
+                'home_team' => $match['home_team'] ?? null,
+                'away_team' => $match['away_team'] ?? null,
+                'competition' => $match['competition'] ?? 'World Cup',
+            ]);
+        }, $rows), 0, $limit);
+    }
+
+    public function betLogStats(int $userId): array
+    {
+        $pending = 0;
+        $totalWon = 0;
+        foreach ($this->bets as $bet) {
+            if ((int)$bet['user_id'] !== $userId) {
+                continue;
+            }
+
+            if ($bet['status'] === 'pending') {
+                $pending++;
+            } elseif ($bet['status'] === 'won') {
+                $totalWon += (int)$bet['potential_payout'];
+            }
+        }
+
+        return [
+            'pending_count' => $pending,
+            'total_won' => $totalWon,
+        ];
+    }
+
+    public function adminBetLogs(array $filters): array
+    {
+        $rows = $this->bets;
+
+        if (!empty($filters['game_id'])) {
+            $rows = array_filter($rows, function (array $bet) use ($filters) {
+                return (string)$bet['game_id'] === (string)$filters['game_id'];
+            });
+        }
+
+        if (!empty($filters['status'])) {
+            $rows = array_filter($rows, function (array $bet) use ($filters) {
+                return $bet['status'] === $filters['status'];
+            });
+        }
+
+        if (!empty($filters['market'])) {
+            $rows = array_filter($rows, function (array $bet) use ($filters) {
+                return $bet['market'] === $filters['market'];
+            });
+        }
+
+        return array_values(array_slice(array_map(function (array $bet) {
+            $match = $bet['match_id'] ? ($this->logMatches[$bet['match_id']] ?? []) : [];
+
+            return array_merge($bet, [
+                'home_team' => $match['home_team'] ?? null,
+                'away_team' => $match['away_team'] ?? null,
+                'competition' => $match['competition'] ?? 'World Cup',
+            ]);
+        }, $rows), 0, (int)($filters['limit'] ?? 100)));
+    }
+}

+ 196 - 0
tests/Unit/WorldCupMatchFavoriteServiceTest.php

@@ -0,0 +1,196 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\WorldCupMatchRepositoryInterface;
+use App\Services\WorldCup\WorldCupMatchFavoriteService;
+use Carbon\Carbon;
+use Tests\TestCase;
+
+class WorldCupMatchFavoriteServiceTest extends TestCase
+{
+    public function test_toggle_favorite_adds_and_removes_match()
+    {
+        $repository = new InMemoryWorldCupMatchRepository();
+        $service = new WorldCupMatchFavoriteService($repository);
+
+        $now = Carbon::parse('2026-06-05 12:00:00');
+        $added = $service->toggleFavorite(1001, 10, $now);
+        $removed = $service->toggleFavorite(1001, 10, $now);
+
+        $this->assertTrue($added['success']);
+        $this->assertTrue($added['is_favorite']);
+        $this->assertTrue($removed['success']);
+        $this->assertFalse($removed['is_favorite']);
+    }
+
+    public function test_match_list_marks_only_current_user_favorites()
+    {
+        $repository = new InMemoryWorldCupMatchRepository();
+        $repository->addFavorite(1001, 10);
+        $repository->addFavorite(2002, 11);
+        $service = new WorldCupMatchFavoriteService($repository);
+
+        $matches = $service->listMatches(1001, false, Carbon::parse('2026-06-05 12:00:00'));
+
+        $this->assertTrue($matches[0]['is_favorite']);
+        $this->assertFalse($matches[1]['is_favorite']);
+    }
+
+    public function test_match_list_keeps_schedule_metadata_for_frontend()
+    {
+        $repository = new InMemoryWorldCupMatchRepository();
+        $service = new WorldCupMatchFavoriteService($repository);
+
+        $matches = $service->listMatches(1001, false, Carbon::parse('2026-06-05 12:00:00'));
+
+        $this->assertSame(1, $matches[0]['match_no']);
+        $this->assertSame('group', $matches[0]['stage']);
+        $this->assertSame('A', $matches[0]['group_name']);
+        $this->assertSame('Mexico City Stadium', $matches[0]['venue']);
+    }
+
+    public function test_match_list_returns_closed_future_schedule_as_not_bettable()
+    {
+        $repository = new InMemoryWorldCupMatchRepository();
+        $service = new WorldCupMatchFavoriteService($repository);
+
+        $matches = $service->listMatches(1001, false, Carbon::parse('2026-06-05 12:00:00'));
+
+        $this->assertSame(73, $matches[2]['match_no']);
+        $this->assertSame('Winner 73 A', $matches[2]['home_team']);
+        $this->assertFalse($matches[2]['is_bettable']);
+    }
+
+    public function test_favorite_filter_returns_only_favorites_and_keeps_cutoff_filter()
+    {
+        $repository = new InMemoryWorldCupMatchRepository();
+        $repository->addFavorite(1001, 10);
+        $repository->addFavorite(1001, 12);
+        $service = new WorldCupMatchFavoriteService($repository);
+
+        $matches = $service->listMatches(1001, true, Carbon::parse('2026-06-05 12:00:00'));
+
+        $this->assertCount(1, $matches);
+        $this->assertSame(10, $matches[0]['match_id']);
+        $this->assertTrue($matches[0]['is_favorite']);
+    }
+}
+
+class InMemoryWorldCupMatchRepository implements WorldCupMatchRepositoryInterface
+{
+    private $favorites = [];
+
+    private $matches = [
+        [
+            'match_id' => 10,
+            'match_no' => 1,
+            'competition' => 'World Cup',
+            'stage' => 'group',
+            'group_name' => 'A',
+            'home_team' => 'Mexico',
+            'away_team' => 'South Africa',
+            'venue' => 'Mexico City Stadium',
+            'kickoff_at' => '2026-06-05 15:00:00',
+            'status' => 'scheduled',
+        ],
+        [
+            'match_id' => 11,
+            'match_no' => 2,
+            'competition' => 'World Cup',
+            'stage' => 'group',
+            'group_name' => 'A',
+            'home_team' => 'France',
+            'away_team' => 'Spain',
+            'venue' => 'Estadio Guadalajara',
+            'kickoff_at' => '2026-06-05 18:00:00',
+            'status' => 'scheduled',
+        ],
+        [
+            'match_id' => 12,
+            'match_no' => 3,
+            'competition' => 'World Cup',
+            'stage' => 'group',
+            'group_name' => 'B',
+            'home_team' => 'Germany',
+            'away_team' => 'Italy',
+            'venue' => 'Toronto Stadium',
+            'kickoff_at' => '2026-06-05 12:30:00',
+            'status' => 'scheduled',
+        ],
+        [
+            'match_id' => 73,
+            'match_no' => 73,
+            'competition' => 'World Cup',
+            'stage' => 'round_32',
+            'group_name' => null,
+            'home_team' => 'Winner 73 A',
+            'away_team' => 'Winner 73 B',
+            'venue' => 'Los Angeles Stadium',
+            'kickoff_at' => '2026-07-01 12:00:00',
+            'status' => 'closed',
+        ],
+    ];
+
+    public function getScheduleMatches(Carbon $now): array
+    {
+        $cutoff = $now->copy()->addHour();
+
+        return array_values(array_filter($this->matches, function (array $match) use ($cutoff) {
+            return in_array($match['status'], ['scheduled', 'closed'], true)
+                && Carbon::parse($match['kickoff_at'])->gt($cutoff);
+        }));
+    }
+
+    public function getOpenMatches(Carbon $now): array
+    {
+        $cutoff = $now->copy()->addHour();
+
+        return array_values(array_filter($this->matches, function (array $match) use ($cutoff) {
+            return $match['status'] === 'scheduled'
+                && Carbon::parse($match['kickoff_at'])->gt($cutoff);
+        }));
+    }
+
+    public function isOpenMatch(int $matchId, Carbon $now): bool
+    {
+        foreach ($this->getOpenMatches($now) as $match) {
+            if ((int)$match['match_id'] === $matchId) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function getFavoriteMatchIds(int $userId): array
+    {
+        return array_values($this->favorites[$userId] ?? []);
+    }
+
+    public function isFavorite(int $userId, int $matchId): bool
+    {
+        return in_array($matchId, $this->favorites[$userId] ?? [], true);
+    }
+
+    public function addFavorite(int $userId, int $matchId): void
+    {
+        if (!isset($this->favorites[$userId])) {
+            $this->favorites[$userId] = [];
+        }
+
+        if (!$this->isFavorite($userId, $matchId)) {
+            $this->favorites[$userId][] = $matchId;
+        }
+    }
+
+    public function removeFavorite(int $userId, int $matchId): void
+    {
+        $this->favorites[$userId] = array_values(array_filter(
+            $this->favorites[$userId] ?? [],
+            function ($favoriteMatchId) use ($matchId) {
+                return (int)$favoriteMatchId !== $matchId;
+            }
+        ));
+    }
+}

+ 22 - 0
tests/Unit/WorldCupOddsAdminViewTest.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+class WorldCupOddsAdminViewTest extends TestCase
+{
+    public function testOddsCellCanBeEditedInlineWithoutPageRefresh(): void
+    {
+        $view = file_get_contents(__DIR__ . '/../../resources/views/admin/world_cup/odds.blade.php');
+
+        $this->assertContains('class="js-current-odds"', $view);
+        $this->assertContains("row.cells[5].addEventListener('click'", $view);
+        $this->assertContains('beginInlineOddsEdit(row, row.cells[5]);', $view);
+        $this->assertContains('event.preventDefault();', $view);
+        $this->assertContains("X-Requested-With', 'XMLHttpRequest'", $view);
+        $this->assertContains('saveInlineOdds(row, input.value);', $view);
+        $this->assertContains('world-cup-odds-ajax-result', $view);
+        $this->assertNotContains("form.scrollIntoView({ behavior: 'smooth', block: 'center' });", $view);
+    }
+}

+ 268 - 0
tests/Unit/WorldCupOddsServiceTest.php

@@ -0,0 +1,268 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\WorldCupOddsRepositoryInterface;
+use App\Services\WorldCup\WorldCupOddsService;
+use Tests\TestCase;
+
+class WorldCupOddsServiceTest extends TestCase
+{
+    public function test_save_odds_updates_previous_odds_and_active_state()
+    {
+        $repository = new InMemoryWorldCupOddsRepository();
+        $service = new WorldCupOddsService($repository);
+
+        $result = $service->saveOdds([
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'decimal_odds' => 2.35,
+            'is_active' => 1,
+            'locked_weight' => 9,
+        ]);
+
+        $this->assertTrue($result['success']);
+        $this->assertSame(2.15, $repository->odds[0]['previous_odds']);
+        $this->assertSame(2.35, $repository->odds[0]['decimal_odds']);
+        $this->assertSame(1, $repository->odds[0]['is_active']);
+        $this->assertSame(9, $repository->odds[0]['locked_weight']);
+    }
+
+    public function test_save_winner_market_allows_null_match_id()
+    {
+        $repository = new InMemoryWorldCupOddsRepository();
+        $service = new WorldCupOddsService($repository);
+
+        $result = $service->saveOdds([
+            'market' => 'winner',
+            'match_id' => null,
+            'selection' => 'Brazil',
+            'decimal_odds' => 6.5,
+            'is_active' => 1,
+            'locked_weight' => 99,
+        ]);
+
+        $this->assertTrue($result['success']);
+        $created = $repository->findOdds('winner', null, 'Brazil');
+        $this->assertSame('winner', $created['market']);
+        $this->assertNull($created['match_id']);
+    }
+
+    public function test_match_odds_only_returns_active_1x2_odds_grouped_by_match()
+    {
+        $repository = new InMemoryWorldCupOddsRepository();
+        $service = new WorldCupOddsService($repository);
+
+        $odds = $service->activeMatchOdds([10, 11]);
+
+        $this->assertArrayHasKey(10, $odds);
+        $this->assertCount(2, $odds[10]);
+        $this->assertSame('home', $odds[10][0]['selection']);
+        $this->assertArrayNotHasKey(11, $odds);
+    }
+
+    public function test_list_odds_includes_match_team_pair_for_admin_table()
+    {
+        $repository = new InMemoryWorldCupOddsRepository();
+        $service = new WorldCupOddsService($repository);
+
+        $odds = $service->listOdds();
+
+        $this->assertSame('Brazil', $odds[0]['home_team']);
+        $this->assertSame('Serbia', $odds[0]['away_team']);
+        $this->assertSame('Brazil vs Serbia', $odds[0]['team_pair']);
+        $this->assertSame('Brazil 胜', $odds[0]['selection_label']);
+        $this->assertSame('平局', $odds[1]['selection_label']);
+        $this->assertSame('-', $odds[3]['team_pair']);
+        $this->assertSame('Brazil', $odds[3]['selection_label']);
+    }
+
+    public function test_validation_rejects_invalid_market_or_decimal_odds()
+    {
+        $service = new WorldCupOddsService(new InMemoryWorldCupOddsRepository());
+
+        $invalidMarket = $service->saveOdds([
+            'market' => 'spread',
+            'match_id' => 10,
+            'selection' => 'home',
+            'decimal_odds' => 2.0,
+        ]);
+        $invalidOdds = $service->saveOdds([
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'decimal_odds' => 1.0,
+        ]);
+
+        $this->assertFalse($invalidMarket['success']);
+        $this->assertSame('Invalid market', $invalidMarket['message']);
+        $this->assertFalse($invalidOdds['success']);
+        $this->assertSame('Decimal odds must be greater than 1', $invalidOdds['message']);
+    }
+
+    public function test_imports_1x2_odds_from_csv_rows()
+    {
+        $repository = new InMemoryWorldCupOddsRepository();
+        $service = new WorldCupOddsService($repository);
+
+        $result = $service->importOddsRows('1x2', [
+            [
+                'match_id' => '10',
+                'home_win' => '2.40',
+                'draw' => '3.25',
+                'away_win' => '3.10',
+            ],
+            [
+                'match_id' => '',
+                'home_win' => '2.00',
+                'draw' => '3.00',
+                'away_win' => '4.00',
+            ],
+        ]);
+
+        $this->assertTrue($result['success']);
+        $this->assertSame(3, $result['data']['updated']);
+        $this->assertSame(0, $result['data']['skipped']);
+        $this->assertCount(1, $result['data']['errors']);
+        $this->assertSame(2.40, $repository->findOdds('1x2', 10, 'home')['decimal_odds']);
+        $this->assertSame(3.25, $repository->findOdds('1x2', 10, 'draw')['decimal_odds']);
+        $this->assertSame(3.10, $repository->findOdds('1x2', 10, 'away')['decimal_odds']);
+        $this->assertSame(2.15, $repository->findOdds('1x2', 10, 'home')['previous_odds']);
+    }
+
+    public function test_imports_winner_odds_from_draftkings_decimal()
+    {
+        $repository = new InMemoryWorldCupOddsRepository();
+        $service = new WorldCupOddsService($repository);
+
+        $result = $service->importOddsRows('winner', [
+            [
+                'team_name' => 'Brazil',
+                'draftkings_decimal' => '10.00',
+            ],
+            [
+                'team_name' => 'Argentina',
+                'draftkings_decimal' => '10.50',
+            ],
+            [
+                'team_name' => 'France',
+                'draftkings_decimal' => '',
+            ],
+        ]);
+
+        $this->assertTrue($result['success']);
+        $this->assertSame(2, $result['data']['updated']);
+        $this->assertCount(1, $result['data']['errors']);
+        $this->assertSame(10.00, $repository->findOdds('winner', null, 'Brazil')['decimal_odds']);
+        $this->assertSame(10.50, $repository->findOdds('winner', null, 'Argentina')['decimal_odds']);
+        $this->assertSame(6.5, $repository->findOdds('winner', null, 'Brazil')['previous_odds']);
+    }
+}
+
+class InMemoryWorldCupOddsRepository implements WorldCupOddsRepositoryInterface
+{
+    public $odds = [
+        [
+            'odds_id' => 1,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'decimal_odds' => 2.15,
+            'previous_odds' => 2.05,
+            'is_active' => 1,
+            'locked_weight' => 1,
+            'home_team' => 'Brazil',
+            'away_team' => 'Serbia',
+        ],
+        [
+            'odds_id' => 2,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'draw',
+            'decimal_odds' => 3.2,
+            'previous_odds' => null,
+            'is_active' => 1,
+            'locked_weight' => 0,
+            'home_team' => 'Brazil',
+            'away_team' => 'Serbia',
+        ],
+        [
+            'odds_id' => 3,
+            'market' => '1x2',
+            'match_id' => 11,
+            'selection' => 'home',
+            'decimal_odds' => 1.8,
+            'previous_odds' => null,
+            'is_active' => 0,
+            'locked_weight' => 0,
+            'home_team' => 'Argentina',
+            'away_team' => 'France',
+        ],
+        [
+            'odds_id' => 4,
+            'market' => 'winner',
+            'match_id' => null,
+            'selection' => 'Brazil',
+            'decimal_odds' => 6.5,
+            'previous_odds' => null,
+            'is_active' => 1,
+            'locked_weight' => 99,
+            'home_team' => null,
+            'away_team' => null,
+        ],
+    ];
+
+    public function allOdds(array $filters = []): array
+    {
+        return $this->odds;
+    }
+
+    public function activeMatchOdds(array $matchIds): array
+    {
+        return array_values(array_filter($this->odds, function (array $odds) use ($matchIds) {
+            return $odds['market'] === '1x2'
+                && in_array((int)$odds['match_id'], $matchIds, true)
+                && (int)$odds['is_active'] === 1;
+        }));
+    }
+
+    public function activeWinnerOdds(): array
+    {
+        return array_values(array_filter($this->odds, function (array $odds) {
+            return $odds['market'] === 'winner' && (int)$odds['is_active'] === 1;
+        }));
+    }
+
+    public function findOdds(string $market, ?int $matchId, string $selection): ?array
+    {
+        foreach ($this->odds as $odds) {
+            if ($odds['market'] === $market
+                && $odds['match_id'] === $matchId
+                && $odds['selection'] === $selection) {
+                return $odds;
+            }
+        }
+
+        return null;
+    }
+
+    public function upsertOdds(array $odds): array
+    {
+        foreach ($this->odds as $index => $current) {
+            if ($current['market'] === $odds['market']
+                && $current['match_id'] === $odds['match_id']
+                && $current['selection'] === $odds['selection']) {
+                $odds['odds_id'] = $current['odds_id'];
+                $this->odds[$index] = array_merge($current, $odds);
+
+                return $this->odds[$index];
+            }
+        }
+
+        $odds['odds_id'] = count($this->odds) + 1;
+        $this->odds[] = $odds;
+
+        return $odds;
+    }
+}

+ 44 - 0
tests/Unit/WorldCupOddsSqlSeedTest.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+class WorldCupOddsSqlSeedTest extends TestCase
+{
+    public function testWinnerOddsUseDraftKingsDecimal(): void
+    {
+        $sql = $this->worldCupSql();
+
+        $this->assertContains("(NULL, 'winner', 'Brazil', 10.00", $sql);
+        $this->assertContains("(NULL, 'winner', 'Argentina', 10.50", $sql);
+        $this->assertContains("(NULL, 'winner', 'France', 5.80", $sql);
+        $this->assertNotContains("(NULL, 'winner', 'Brazil', 9.50", $sql);
+        $this->assertNotContains("(NULL, 'winner', 'Argentina', 10.17", $sql);
+    }
+
+    public function testPostedRoundOneMatchOddsAreSeededByExistingMatchId(): void
+    {
+        $sql = $this->worldCupSql();
+
+        $this->assertContains("(1, '1x2', 'home', 1.40", $sql);
+        $this->assertContains("(1, '1x2', 'draw', 4.60", $sql);
+        $this->assertContains("(1, '1x2', 'away', 8.00", $sql);
+        $this->assertContains("(7, '1x2', 'home', 2.05", $sql);
+        $this->assertContains("(7, '1x2', 'away', 3.90", $sql);
+    }
+
+    public function testOddsSeedUsesMergeForRepeatableDeploys(): void
+    {
+        $sql = $this->worldCupSql();
+
+        $this->assertContains('MERGE agent.dbo.world_cup_odds AS target', $sql);
+        $this->assertContains('WHEN MATCHED THEN', $sql);
+        $this->assertContains('WHEN NOT MATCHED THEN', $sql);
+    }
+
+    private function worldCupSql(): string
+    {
+        return file_get_contents(__DIR__ . '/../../database/world_cup_activity.sql');
+    }
+}

+ 448 - 0
tests/Unit/WorldCupReferralRewardServiceTest.php

@@ -0,0 +1,448 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\WorldCupReferralRepositoryInterface;
+use App\Services\WorldCup\WorldCupReferralRewardService;
+use App\Game\GlobalUserInfo;
+use Tests\TestCase;
+
+class WorldCupReferralRewardServiceTest extends TestCase
+{
+    public function test_first_deposit_creates_reviewing_reward_for_both_users()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->bindReferral(1001, 1002, 'manual');
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->handleFirstDeposit(1002, 120.0, 'order-120');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('created', $result['status']);
+        $this->assertCount(1, $repository->rewards);
+        $this->assertSame(1001, $repository->rewards[0]['referrer_id']);
+        $this->assertSame(1002, $repository->rewards[0]['invitee_id']);
+        $this->assertSame(12000, $repository->rewards[0]['first_deposit_amt']);
+        $this->assertSame(6000, $repository->rewards[0]['reward_each']);
+        $this->assertSame(12000, $repository->rewards[0]['total_liability']);
+        $this->assertSame('reviewing', $repository->rewards[0]['status']);
+    }
+
+    public function test_reward_caps_at_one_hundred_dollars_each_and_two_hundred_total()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->bindReferral(1001, 1002, 'manual');
+        $service = new WorldCupReferralRewardService($repository);
+
+        $service->handleFirstDeposit(1002, 300.0, 'order-300');
+
+        $this->assertSame(10000, $repository->rewards[0]['reward_each']);
+        $this->assertSame(20000, $repository->rewards[0]['total_liability']);
+    }
+
+    public function test_duplicate_order_is_idempotent()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->bindReferral(1001, 1002, 'manual');
+        $service = new WorldCupReferralRewardService($repository);
+
+        $first = $service->handleFirstDeposit(1002, 120.0, 'order-120');
+        $second = $service->handleFirstDeposit(1002, 120.0, 'order-120');
+
+        $this->assertSame('created', $first['status']);
+        $this->assertSame('exists', $second['status']);
+        $this->assertCount(1, $repository->rewards);
+    }
+
+    public function test_current_order_not_marked_successful_yet_still_creates_first_deposit_reward()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->bindReferral(1001, 1002, 'manual');
+        $repository->successfulOrderSnByUser[1002] = [];
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->handleFirstDeposit(1002, 120.0, 'order-pending-status');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('created', $result['status']);
+        $this->assertCount(1, $repository->rewards);
+        $this->assertSame('order-pending-status', $repository->rewards[0]['first_deposit_order_sn']);
+    }
+
+    public function test_non_first_deposit_does_not_create_reward()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->bindReferral(1001, 1002, 'manual');
+        $repository->successfulOrderSnByUser[1002] = ['order-old'];
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->handleFirstDeposit(1002, 120.0, 'order-new');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('not_first_deposit', $result['status']);
+        $this->assertCount(0, $repository->rewards);
+    }
+
+    public function test_unbound_invitee_does_not_create_reward()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->handleFirstDeposit(1002, 120.0, 'order-120');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('not_bound', $result['status']);
+        $this->assertCount(0, $repository->rewards);
+    }
+
+    public function test_bind_invite_code_prevents_self_invite_and_duplicate_binding()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->ensureUserState(1001);
+        $inviteCode = $repository->states[1001]['invite_code'];
+        $service = new WorldCupReferralRewardService($repository);
+
+        $self = $service->bindInvite(1001, $inviteCode, 'manual');
+        $bound = $service->bindInvite(1002, $inviteCode, 'manual');
+        $duplicate = $service->bindInvite(1002, $inviteCode, 'manual');
+
+        $this->assertFalse($self['success']);
+        $this->assertSame('Cannot invite yourself', $self['message']);
+        $this->assertTrue($bound['success']);
+        $this->assertSame(1001, $repository->states[1002]['referred_by_user_id']);
+        $this->assertTrue($duplicate['success']);
+        $this->assertSame('exists', $duplicate['status']);
+    }
+
+    public function test_invite_log_returns_stats_and_invited_users()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->bindReferral(1001, 1002, 'manual');
+        $repository->bindReferral(1001, 1003, 'manual');
+        $repository->createReward([
+            'referrer_id' => 1001,
+            'invitee_id' => 1002,
+            'first_deposit_order_sn' => 'order-120',
+            'first_deposit_amt' => 12000,
+            'reward_each' => 6000,
+            'total_liability' => 12000,
+            'risk_score' => 0,
+            'risk_level' => 'low',
+            'signals' => '[]',
+            'status' => 'reviewing',
+        ]);
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->inviteLog(1001, 'invited', 20);
+
+        $this->assertSame(2, $result['stats']['invited_count']);
+        $this->assertSame(1, $result['stats']['deposited_count']);
+        $this->assertSame(1, $result['stats']['awaiting_count']);
+        $this->assertSame(1002, $result['list'][0]['invitee_id']);
+        $this->assertSame('deposited', $result['list'][0]['status']);
+        $this->assertSame(12000, $result['list'][0]['first_deposit_amt']);
+        $this->assertSame(1003, $result['list'][1]['invitee_id']);
+        $this->assertSame('awaiting', $result['list'][1]['status']);
+    }
+
+    public function test_invite_log_can_return_first_deposits_only()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->bindReferral(1001, 1002, 'manual');
+        $repository->bindReferral(1001, 1003, 'manual');
+        $repository->createReward([
+            'referrer_id' => 1001,
+            'invitee_id' => 1002,
+            'first_deposit_order_sn' => 'order-120',
+            'first_deposit_amt' => 12000,
+            'reward_each' => 6000,
+            'total_liability' => 12000,
+            'risk_score' => 0,
+            'risk_level' => 'low',
+            'signals' => '[]',
+            'status' => 'reviewing',
+        ]);
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->inviteLog(1001, 'deposits', 20);
+
+        $this->assertCount(1, $result['list']);
+        $this->assertSame(1002, $result['list'][0]['invitee_id']);
+    }
+
+    public function test_reward_log_returns_reviewing_and_paid_amounts()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->createReward([
+            'referrer_id' => 1001,
+            'invitee_id' => 1002,
+            'first_deposit_order_sn' => 'order-120',
+            'first_deposit_amt' => 12000,
+            'reward_each' => 6000,
+            'total_liability' => 12000,
+            'risk_score' => 0,
+            'risk_level' => 'low',
+            'signals' => '[]',
+            'status' => 'reviewing',
+        ]);
+        $repository->createReward([
+            'referrer_id' => 1001,
+            'invitee_id' => 1003,
+            'first_deposit_order_sn' => 'order-40',
+            'first_deposit_amt' => 4000,
+            'reward_each' => 2000,
+            'total_liability' => 4000,
+            'risk_score' => 0,
+            'risk_level' => 'low',
+            'signals' => '[]',
+            'status' => 'approved',
+        ]);
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->rewardLog(1001, 20);
+
+        $this->assertSame(6000, $result['stats']['reviewing_amount']);
+        $this->assertSame(2000, $result['stats']['paid_amount']);
+        $this->assertSame('reviewing', $result['list'][0]['status']);
+        $this->assertSame('paid', $result['list'][1]['status']);
+        $this->assertSame(GlobalUserInfo::faceidToAvatar(2), $result['list'][0]['avatar']);
+    }
+
+    public function test_reward_log_only_returns_rewards_invited_by_current_user()
+    {
+        $repository = new InMemoryWorldCupReferralRepository();
+        $repository->createReward([
+            'referrer_id' => 1001,
+            'invitee_id' => 1002,
+            'first_deposit_order_sn' => 'order-own',
+            'first_deposit_amt' => 12000,
+            'reward_each' => 6000,
+            'total_liability' => 12000,
+            'risk_score' => 0,
+            'risk_level' => 'low',
+            'signals' => '[]',
+            'status' => 'reviewing',
+        ]);
+        $repository->createReward([
+            'referrer_id' => 9999,
+            'invitee_id' => 1001,
+            'first_deposit_order_sn' => 'order-parent',
+            'first_deposit_amt' => 8000,
+            'reward_each' => 4000,
+            'total_liability' => 8000,
+            'risk_score' => 0,
+            'risk_level' => 'low',
+            'signals' => '[]',
+            'status' => 'approved',
+        ]);
+        $service = new WorldCupReferralRewardService($repository);
+
+        $result = $service->rewardLog(1001, 20);
+
+        $this->assertSame(6000, $result['stats']['reviewing_amount']);
+        $this->assertSame(0, $result['stats']['paid_amount']);
+        $this->assertCount(1, $result['list']);
+        $this->assertSame(1002, $result['list'][0]['invitee_id']);
+    }
+}
+
+class InMemoryWorldCupReferralRepository implements WorldCupReferralRepositoryInterface
+{
+    public $states = [];
+
+    public $referrals = [];
+
+    public $rewards = [];
+
+    public $firstOrderSnByUser = [];
+
+    public $successfulOrderSnByUser = [];
+
+    public function ensureUserState(int $userId): array
+    {
+        if (!isset($this->states[$userId])) {
+            $this->states[$userId] = [
+                'user_id' => $userId,
+                'invite_code' => 'WC' . $userId,
+                'referred_by_user_id' => null,
+                'referral_bind_at' => null,
+                'referral_bind_type' => null,
+                'device_fp' => null,
+                'pay_account_hash' => null,
+                'signup_ip' => null,
+            ];
+        }
+
+        return $this->states[$userId];
+    }
+
+    public function findUserState(int $userId): ?array
+    {
+        return $this->states[$userId] ?? null;
+    }
+
+    public function findUserByInviteCode(string $inviteCode): ?array
+    {
+        foreach ($this->states as $state) {
+            if ($state['invite_code'] === $inviteCode) {
+                return $state;
+            }
+        }
+
+        return null;
+    }
+
+    public function findReferralByInvitee(int $inviteeId): ?array
+    {
+        return $this->referrals[$inviteeId] ?? null;
+    }
+
+    public function bindReferral(int $referrerId, int $inviteeId, string $bindType): array
+    {
+        $this->ensureUserState($referrerId);
+        $this->ensureUserState($inviteeId);
+        $this->referrals[$inviteeId] = [
+            'referrer_id' => $referrerId,
+            'invitee_id' => $inviteeId,
+            'bind_type' => $bindType,
+        ];
+        $this->states[$inviteeId]['referred_by_user_id'] = $referrerId;
+        $this->states[$inviteeId]['referral_bind_type'] = $bindType;
+        $this->states[$inviteeId]['referral_bind_at'] = '2026-06-05 12:00:00';
+
+        return $this->referrals[$inviteeId];
+    }
+
+    public function isFirstSuccessfulOrder(int $userId, string $orderSn): bool
+    {
+        if (array_key_exists($userId, $this->successfulOrderSnByUser)) {
+            $successfulOrders = $this->successfulOrderSnByUser[$userId];
+            if (!in_array($orderSn, $successfulOrders, true)) {
+                return count($successfulOrders) === 0;
+            }
+
+            return reset($successfulOrders) === $orderSn;
+        }
+
+        return ($this->firstOrderSnByUser[$userId] ?? $orderSn) === $orderSn;
+    }
+
+    public function findRewardByInvitee(int $inviteeId): ?array
+    {
+        foreach ($this->rewards as $reward) {
+            if ((int)$reward['invitee_id'] === $inviteeId) {
+                return $reward;
+            }
+        }
+
+        return null;
+    }
+
+    public function createReward(array $reward): array
+    {
+        $reward['reward_id'] = count($this->rewards) + 1;
+        $this->rewards[] = $reward;
+
+        return $reward;
+    }
+
+    public function paidOrdersMissingRewards(int $limit): array
+    {
+        return [];
+    }
+
+    public function inviteLogs(int $referrerId, string $type, int $limit): array
+    {
+        $rows = [];
+        foreach ($this->referrals as $referral) {
+            if ((int)$referral['referrer_id'] !== $referrerId) {
+                continue;
+            }
+
+            $reward = $this->findRewardByInvitee((int)$referral['invitee_id']);
+            if ($type === 'deposits' && !$reward) {
+                continue;
+            }
+
+            $rows[] = [
+                'invitee_id' => (int)$referral['invitee_id'],
+                'game_id' => (int)$referral['invitee_id'],
+                'bind_at' => $referral['bind_at'] ?? '2026-06-05 12:00:00',
+                'first_deposit_amt' => $reward['first_deposit_amt'] ?? 0,
+                'reward_each' => $reward['reward_each'] ?? 0,
+                'reward_status' => $reward['status'] ?? null,
+                'status' => $reward ? 'deposited' : 'awaiting',
+            ];
+        }
+
+        return array_slice($rows, 0, $limit);
+    }
+
+    public function inviteStats(int $referrerId): array
+    {
+        $invited = 0;
+        $deposited = 0;
+        foreach ($this->referrals as $referral) {
+            if ((int)$referral['referrer_id'] !== $referrerId) {
+                continue;
+            }
+
+            $invited++;
+            if ($this->findRewardByInvitee((int)$referral['invitee_id'])) {
+                $deposited++;
+            }
+        }
+
+        return [
+            'invited_count' => $invited,
+            'deposited_count' => $deposited,
+            'awaiting_count' => $invited - $deposited,
+        ];
+    }
+
+    public function rewardLogs(int $userId, int $limit): array
+    {
+        $rows = [];
+        foreach ($this->rewards as $reward) {
+            if ((int)$reward['referrer_id'] !== $userId) {
+                continue;
+            }
+
+            $rows[] = [
+                'reward_id' => (int)$reward['reward_id'],
+                'referrer_id' => (int)$reward['referrer_id'],
+                'invitee_id' => (int)$reward['invitee_id'],
+                'friend_user_id' => (int)$reward['invitee_id'],
+                'avatar' => GlobalUserInfo::faceidToAvatar((int)$reward['invitee_id'] - 1000),
+                'first_deposit_amt' => (int)$reward['first_deposit_amt'],
+                'reward_each' => (int)$reward['reward_each'],
+                'status' => $reward['status'] === 'approved' ? 'paid' : $reward['status'],
+                'submitted_at' => $reward['submitted_at'] ?? '2026-06-05 12:00:00',
+            ];
+        }
+
+        return array_slice($rows, 0, $limit);
+    }
+
+    public function rewardStats(int $userId): array
+    {
+        $reviewing = 0;
+        $paid = 0;
+        foreach ($this->rewards as $reward) {
+            if ((int)$reward['referrer_id'] !== $userId) {
+                continue;
+            }
+
+            if ($reward['status'] === 'approved') {
+                $paid += (int)$reward['reward_each'];
+            } elseif (in_array($reward['status'], ['reviewing', 'on_hold'], true)) {
+                $reviewing += (int)$reward['reward_each'];
+            }
+        }
+
+        return [
+            'reviewing_amount' => $reviewing,
+            'paid_amount' => $paid,
+        ];
+    }
+}

+ 45 - 0
tests/Unit/WorldCupReferralServiceTest.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\WorldCupReferralService;
+use Tests\TestCase;
+
+class WorldCupReferralServiceTest extends TestCase
+{
+    public function test_first_deposit_one_hundred_twenty_rewards_sixty_each()
+    {
+        $service = new WorldCupReferralService();
+
+        $reward = $service->calculateReward(12000);
+
+        $this->assertSame(6000, $reward['reward_each']);
+        $this->assertSame(12000, $reward['total_liability']);
+    }
+
+    public function test_first_deposit_three_hundred_caps_reward_at_one_hundred_each()
+    {
+        $service = new WorldCupReferralService();
+
+        $reward = $service->calculateReward(30000);
+
+        $this->assertSame(10000, $reward['reward_each']);
+        $this->assertSame(20000, $reward['total_liability']);
+    }
+
+    public function test_invite_copy_matches_latest_frontend_requirement()
+    {
+        $service = new WorldCupReferralService();
+
+        $copy = $service->getInviteCopy();
+
+        $this->assertSame(
+            'When your friend makes their first deposit, you each get 50% of it (up to $100 each)',
+            $copy['description']
+        );
+        $this->assertSame(
+            'Risk warning: Self-invites (same device/payment) and bulk or bot signups are banned — rewards frozen, permanent ban, clawback.',
+            $copy['risk_warning']
+        );
+    }
+}

+ 116 - 0
tests/Unit/WorldCupRepositoryNoLockTest.php

@@ -0,0 +1,116 @@
+<?php
+
+namespace Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+class WorldCupRepositoryNoLockTest extends TestCase
+{
+    public function testReadConnectionTableQueriesUseNoLock(): void
+    {
+        foreach ($this->repositoryFiles() as $file) {
+            $content = file_get_contents($file);
+            preg_match_all(
+                "/DB::connection\\('read'\\)->table\\([\\s\\S]*?->(?:first|get|exists|pluck|value)\\(/",
+                $content,
+                $matches
+            );
+
+            foreach ($matches[0] as $query) {
+                $this->assertContains(
+                    "->lock('WITH (NOLOCK)')",
+                    $query,
+                    basename($file) . ' has a read query without WITH (NOLOCK): ' . $this->compactQuery($query)
+                );
+            }
+        }
+    }
+
+    public function testJoinedTablesUseNoLockHints(): void
+    {
+        foreach ($this->repositoryFiles() as $file) {
+            $content = file_get_contents($file);
+            preg_match_all('/->(?:leftJoin|join)\\(([\\s\\S]*?)\\n\\s*\\)/', $content, $matches);
+
+            foreach ($matches[0] as $join) {
+                $this->assertContains(
+                    'WITH (NOLOCK)',
+                    $join,
+                    basename($file) . ' has a joined table without WITH (NOLOCK): ' . $this->compactQuery($join)
+                );
+            }
+        }
+    }
+
+    public function testDynamicConnectionReadQueriesUseNoLockBranch(): void
+    {
+        foreach ($this->repositoryFiles() as $file) {
+            $content = file_get_contents($file);
+            preg_match_all(
+                '/DB::connection\\(\\$connection\\)->table\\([\\s\\S]*?->(?:first|get|exists|pluck|value)\\(/',
+                $content,
+                $matches
+            );
+
+            foreach ($matches[0] as $query) {
+                $this->assertContains(
+                    "if (\$connection === 'read')",
+                    $query,
+                    basename($file) . ' has a dynamic read query without a read-only branch: '
+                        . $this->compactQuery($query)
+                );
+                $this->assertContains(
+                    "->lock('WITH (NOLOCK)')",
+                    $query,
+                    basename($file) . ' has a dynamic read query without WITH (NOLOCK): '
+                        . $this->compactQuery($query)
+                );
+            }
+        }
+    }
+
+    public function testSettlementUsesStoredBetOddsInsteadOfCurrentOdds(): void
+    {
+        $content = file_get_contents(
+            __DIR__ . '/../../app/Services/WorldCup/Repositories/SqlWorldCupSettlementRepository.php'
+        );
+
+        $this->assertNotContains('applyCurrentOdds', $content);
+        $this->assertNotContains('currentOdds', $content);
+        $this->assertNotContains('self::ODDS_TABLE', $this->methodBody($content, 'payBet'));
+    }
+
+    public function testReferralRewardLogOnlyFiltersCurrentUserAsReferrer(): void
+    {
+        $content = file_get_contents(
+            __DIR__ . '/../../app/Services/WorldCup/Repositories/SqlWorldCupReferralRepository.php'
+        );
+        $rewardLogs = $this->methodBody($content, 'rewardLogs');
+        $rewardStats = $this->methodBody($content, 'rewardStats');
+
+        $this->assertContains("->where('rw.referrer_id', \$userId)", $rewardLogs);
+        $this->assertNotContains("->orWhere('rw.invitee_id', \$userId)", $rewardLogs);
+        $this->assertContains("->where('referrer_id', \$userId)", $rewardStats);
+        $this->assertNotContains("->orWhere('invitee_id', \$userId)", $rewardStats);
+    }
+
+    private function repositoryFiles(): array
+    {
+        return glob(__DIR__ . '/../../app/Services/WorldCup/Repositories/SqlWorldCup*Repository.php');
+    }
+
+    private function compactQuery(string $query): string
+    {
+        return preg_replace('/\\s+/', ' ', trim($query));
+    }
+
+    private function methodBody(string $content, string $method): string
+    {
+        $pattern = '/public function ' . preg_quote($method, '/') . '\\([\\s\\S]*?\\n    }/';
+        preg_match($pattern, $content, $matches);
+
+        $this->assertNotEmpty($matches, 'Method not found: ' . $method);
+
+        return $matches[0];
+    }
+}

+ 62 - 0
tests/Unit/WorldCupReviewRepositoryMailCopyTest.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\SqlWorldCupReviewRepository;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+
+class WorldCupReviewRepositoryMailCopyTest extends TestCase
+{
+    public function testInviteeApprovedMailUsesWelcomeRewardCopy(): void
+    {
+        $repository = new SqlWorldCupReviewRepository();
+        $reward = $this->reward();
+
+        $title = $this->callPrivate($repository, 'approvedInviteeMailTitle', [$reward]);
+        $text = $this->callPrivate($repository, 'approvedInviteeMailText', [$reward]);
+
+        $this->assertSame('Claim your $60 welcome reward', $title);
+        $this->assertContains('Welcome! Your referral reward is ready to claim.', $text);
+        $this->assertContains('You signed up with an invite and made your first deposit of $120.', $text);
+        $this->assertContains('Reward: $60 (50% of your first deposit).', $text);
+        $this->assertContains('Tap Claim to add it to your balance.', $text);
+    }
+
+    public function testInviteeRejectedMailUsesWelcomeRewardCopy(): void
+    {
+        $repository = new SqlWorldCupReviewRepository();
+        $reward = $this->reward();
+
+        $title = $this->callPrivate($repository, 'rejectedInviteeMailTitle', [$reward]);
+        $text = $this->callPrivate($repository, 'rejectedInviteeMailText', [$reward, 'Same device']);
+
+        $this->assertSame('Welcome reward not approved', $title);
+        $this->assertContains('Your welcome reward could not be approved.', $text);
+        $this->assertContains('Reason: Same device.', $text);
+        $this->assertContains('If you think this is a mistake, contact support.', $text);
+    }
+
+    private function reward(): array
+    {
+        return [
+            'reward_id' => 1,
+            'referrer_id' => 1001,
+            'invitee_id' => 1002,
+            'first_deposit_amt' => 12000,
+            'reward_each' => 6000,
+            'total_liability' => 12000,
+            'risk_level' => 'low',
+            'status' => 'reviewing',
+        ];
+    }
+
+    private function callPrivate(object $object, string $method, array $arguments)
+    {
+        $reflection = new ReflectionClass($object);
+        $method = $reflection->getMethod($method);
+        $method->setAccessible(true);
+
+        return $method->invokeArgs($object, $arguments);
+    }
+}

+ 252 - 0
tests/Unit/WorldCupReviewServiceTest.php

@@ -0,0 +1,252 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\WorldCupReviewRepositoryInterface;
+use App\Services\WorldCup\WorldCupReviewService;
+use Tests\TestCase;
+
+class WorldCupReviewServiceTest extends TestCase
+{
+    public function test_approve_sends_claim_mail_and_writes_audit()
+    {
+        $repository = new InMemoryWorldCupReviewRepository();
+        $service = new WorldCupReviewService($repository);
+
+        $result = $service->approve(1, 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('approved', $repository->rewards[1]['status']);
+        $this->assertSame(6000, $repository->mails[1001][0]['amount']);
+        $this->assertSame(6000, $repository->mails[1002][0]['amount']);
+        $this->assertSame('Claim your $60 referral reward', $repository->mails[1001][0]['title']);
+        $this->assertSame('Claim your $60 welcome reward', $repository->mails[1002][0]['title']);
+        $this->assertStringContainsString('Reward: $60', $repository->mails[1001][0]['text']);
+        $this->assertStringContainsString('first deposit of $120', $repository->mails[1002][0]['text']);
+        $this->assertSame('approve', $repository->audits[0]['action']);
+    }
+
+    public function test_reject_requires_reason_and_writes_audit()
+    {
+        $repository = new InMemoryWorldCupReviewRepository();
+        $service = new WorldCupReviewService($repository);
+
+        $missingReason = $service->reject(1, 'admin-01', '');
+        $result = $service->reject(1, 'admin-01', 'same_device');
+
+        $this->assertFalse($missingReason['success']);
+        $this->assertSame('Reason is required', $missingReason['message']);
+        $this->assertTrue($result['success']);
+        $this->assertSame('rejected', $repository->rewards[1]['status']);
+        $this->assertSame(WorldCupReviewService::REJECT_REASON, $repository->rewards[1]['reason_code']);
+        $this->assertSame('Your referral reward could not be approved', $repository->mails[1001][0]['title']);
+        $this->assertSame('Welcome reward not approved', $repository->mails[1002][0]['title']);
+        $this->assertStringContainsString(WorldCupReviewService::REJECT_REASON, $repository->mails[1001][0]['text']);
+        $this->assertStringContainsString('Your welcome reward could not be approved.', $repository->mails[1002][0]['text']);
+        $this->assertSame('reject', $repository->audits[0]['action']);
+    }
+
+    public function test_hold_moves_reviewing_reward_to_on_hold()
+    {
+        $repository = new InMemoryWorldCupReviewRepository();
+        $service = new WorldCupReviewService($repository);
+
+        $result = $service->hold(1, 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('on_hold', $repository->rewards[1]['status']);
+        $this->assertSame('hold', $repository->audits[0]['action']);
+    }
+
+    public function test_clawback_only_allows_approved_reward()
+    {
+        $repository = new InMemoryWorldCupReviewRepository();
+        $service = new WorldCupReviewService($repository);
+
+        $notApproved = $service->clawback(1, 'admin-01', 'fraud', true);
+        $approved = $service->clawback(3, 'admin-01', 'fraud', true);
+
+        $this->assertFalse($notApproved['success']);
+        $this->assertSame('Only approved rewards can be clawed back', $notApproved['message']);
+        $this->assertTrue($approved['success']);
+        $this->assertSame('clawed_back', $repository->rewards[3]['status']);
+        $this->assertSame(1001, $repository->banned[0]);
+        $this->assertSame(1004, $repository->banned[1]);
+        $this->assertSame(10000, $repository->clawedBack[1001]);
+        $this->assertSame(10000, $repository->clawedBack[1004]);
+    }
+
+    public function test_batch_approve_rejects_high_risk_rewards()
+    {
+        $repository = new InMemoryWorldCupReviewRepository();
+        $service = new WorldCupReviewService($repository);
+
+        $result = $service->batchApprove([1, 2], 'admin-01');
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('High risk rewards must be reviewed one by one', $result['message']);
+        $this->assertSame('reviewing', $repository->rewards[1]['status']);
+        $this->assertSame('reviewing', $repository->rewards[2]['status']);
+    }
+
+    public function test_batch_reject_requires_reason()
+    {
+        $repository = new InMemoryWorldCupReviewRepository();
+        $service = new WorldCupReviewService($repository);
+
+        $missingReason = $service->batchReject([1, 2], 'admin-01', '');
+        $result = $service->batchReject([1, 2], 'admin-01', 'bulk_signup');
+
+        $this->assertFalse($missingReason['success']);
+        $this->assertSame('Reason is required', $missingReason['message']);
+        $this->assertTrue($result['success']);
+        $this->assertSame('rejected', $repository->rewards[1]['status']);
+        $this->assertSame('rejected', $repository->rewards[2]['status']);
+    }
+}
+
+class InMemoryWorldCupReviewRepository implements WorldCupReviewRepositoryInterface
+{
+    public $rewards = [
+        1 => [
+            'reward_id' => 1,
+            'referrer_id' => 1001,
+            'invitee_id' => 1002,
+            'first_deposit_amt' => 12000,
+            'reward_each' => 6000,
+            'total_liability' => 12000,
+            'risk_level' => 'low',
+            'status' => 'reviewing',
+        ],
+        2 => [
+            'reward_id' => 2,
+            'referrer_id' => 1001,
+            'invitee_id' => 1003,
+            'first_deposit_amt' => 30000,
+            'reward_each' => 10000,
+            'total_liability' => 20000,
+            'risk_level' => 'high',
+            'status' => 'reviewing',
+        ],
+        3 => [
+            'reward_id' => 3,
+            'referrer_id' => 1001,
+            'invitee_id' => 1004,
+            'first_deposit_amt' => 30000,
+            'reward_each' => 10000,
+            'total_liability' => 20000,
+            'risk_level' => 'low',
+            'status' => 'approved',
+        ],
+    ];
+
+    public $mails = [];
+
+    public $clawedBack = [];
+
+    public $banned = [];
+
+    public $audits = [];
+
+    public function findReward(int $rewardId): ?array
+    {
+        return $this->rewards[$rewardId] ?? null;
+    }
+
+    public function findRewards(array $rewardIds): array
+    {
+        return array_values(array_filter($this->rewards, function (array $reward) use ($rewardIds) {
+            return in_array((int)$reward['reward_id'], $rewardIds, true);
+        }));
+    }
+
+    public function updateRewardStatus(
+        int $rewardId,
+        string $status,
+        string $actor,
+        string $reasonCode = null
+    ): void {
+        $this->rewards[$rewardId]['status'] = $status;
+        $this->rewards[$rewardId]['review_by'] = $actor;
+        $this->rewards[$rewardId]['reason_code'] = $reasonCode;
+    }
+
+    public function payReward(array $reward): void
+    {
+        $this->mails[$reward['referrer_id']][] = [
+            'title' => 'Claim your $60 referral reward',
+            'text' => 'Reward: $60',
+            'amount' => $reward['reward_each'],
+        ];
+        $this->mails[$reward['invitee_id']][] = [
+            'title' => 'Claim your $60 welcome reward',
+            'text' => 'Welcome! Your referral reward is ready to claim. '
+                . 'You signed up with an invite and made your first deposit of $120. '
+                . 'Reward: $60 (50% of your first deposit). '
+                . 'Tap Claim to add it to your balance.',
+            'amount' => $reward['reward_each'],
+        ];
+    }
+
+    public function sendRejectMail(array $reward, string $reason): void
+    {
+        $this->mails[$reward['referrer_id']][] = [
+            'title' => 'Your referral reward could not be approved',
+            'text' => 'Reason: ' . $reason,
+            'amount' => 0,
+        ];
+        $this->mails[$reward['invitee_id']][] = [
+            'title' => 'Welcome reward not approved',
+            'text' => 'Your welcome reward could not be approved.' . "\n"
+                . 'Reason: ' . $reason . '.' . "\n"
+                . 'If you think this is a mistake, contact support.',
+            'amount' => 0,
+        ];
+    }
+
+    public function clawbackReward(array $reward, bool $banUsers): void
+    {
+        $this->clawedBack[$reward['referrer_id']] = ($this->clawedBack[$reward['referrer_id']] ?? 0) + $reward['reward_each'];
+        $this->clawedBack[$reward['invitee_id']] = ($this->clawedBack[$reward['invitee_id']] ?? 0) + $reward['reward_each'];
+
+        if ($banUsers) {
+            $this->banned[] = $reward['referrer_id'];
+            $this->banned[] = $reward['invitee_id'];
+        }
+    }
+
+    public function writeAudit(
+        int $rewardId,
+        string $actor,
+        string $action,
+        ?string $reasonCode,
+        ?string $beforeStatus,
+        ?string $afterStatus,
+        array $payload = []
+    ): void {
+        $this->audits[] = compact(
+            'rewardId',
+            'actor',
+            'action',
+            'reasonCode',
+            'beforeStatus',
+            'afterStatus',
+            'payload'
+        );
+    }
+
+    public function queue(array $filters): array
+    {
+        return [];
+    }
+
+    public function kpi(): array
+    {
+        return [];
+    }
+
+    public function auditLogs($filters): array
+    {
+        return [];
+    }
+}

+ 268 - 0
tests/Unit/WorldCupScheduleUpdateServiceTest.php

@@ -0,0 +1,268 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\WorldCupScheduleRepositoryInterface;
+use App\Services\WorldCup\WorldCupScheduleUpdateService;
+use Tests\TestCase;
+
+class WorldCupScheduleUpdateServiceTest extends TestCase
+{
+    public function test_lists_all_matches_for_admin_form()
+    {
+        $repository = new InMemoryWorldCupScheduleRepository();
+        $service = new WorldCupScheduleUpdateService($repository);
+
+        $matches = $service->allMatches();
+
+        $this->assertCount(3, $matches);
+        $this->assertSame(72, $matches[0]['match_no']);
+        $this->assertSame(104, $matches[2]['match_no']);
+    }
+
+    public function test_updates_checked_form_rows_across_all_schedule()
+    {
+        $repository = new InMemoryWorldCupScheduleRepository();
+        $service = new WorldCupScheduleUpdateService($repository);
+
+        $result = $service->updateMatches([
+            [
+                'enabled' => '1',
+                'match_no' => 72,
+                'home_team' => 'Croatia Updated',
+                'away_team' => 'Ghana',
+                'venue' => 'Philadelphia',
+                'kickoff_at' => '2026-06-27 21:00:00',
+                'status' => 'scheduled',
+            ],
+            [
+                'enabled' => '0',
+                'match_no' => 104,
+                'home_team' => 'France',
+                'away_team' => 'Brazil',
+                'status' => 'closed',
+            ],
+        ], 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame(1, $result['data']['updated']);
+        $this->assertSame(1, $result['data']['skipped']);
+        $this->assertSame('Croatia Updated', $repository->matches[72]['home_team']);
+        $this->assertSame('Winner 104 A', $repository->matches[104]['home_team']);
+    }
+
+    public function test_updates_only_unresolved_matches_for_selected_date()
+    {
+        $repository = new InMemoryWorldCupScheduleRepository();
+        $service = new WorldCupScheduleUpdateService($repository);
+
+        $result = $service->updateUnresolvedMatches('2026-07-19', [
+            [
+                'match_no' => 104,
+                'home_team' => 'France',
+                'away_team' => 'Brazil',
+                'venue' => 'East Rutherford',
+            ],
+            [
+                'match_no' => 103,
+                'home_team' => 'Argentina',
+                'away_team' => 'England',
+            ],
+            [
+                'match_no' => 72,
+                'home_team' => 'Croatia',
+                'away_team' => 'Ghana',
+            ],
+        ], 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame(1, $result['data']['updated']);
+        $this->assertSame(2, $result['data']['skipped']);
+        $this->assertSame('France', $repository->matches[104]['home_team']);
+        $this->assertSame('Brazil', $repository->matches[104]['away_team']);
+        $this->assertSame('closed', $repository->matches[104]['status']);
+        $this->assertSame('Winner 103 A', $repository->matches[103]['home_team']);
+        $this->assertSame('Croatia', $repository->matches[72]['home_team']);
+        $this->assertSame('schedule_update', $repository->audits[0]['action']);
+    }
+
+    public function test_allows_operator_to_open_match_when_status_is_scheduled()
+    {
+        $repository = new InMemoryWorldCupScheduleRepository();
+        $service = new WorldCupScheduleUpdateService($repository);
+
+        $result = $service->updateUnresolvedMatches('2026-07-19', [
+            [
+                'match_no' => 104,
+                'home_team' => 'France',
+                'away_team' => 'Brazil',
+                'status' => 'scheduled',
+            ],
+        ], 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('scheduled', $repository->matches[104]['status']);
+    }
+
+    public function test_rejects_invalid_payload_rows()
+    {
+        $repository = new InMemoryWorldCupScheduleRepository();
+        $service = new WorldCupScheduleUpdateService($repository);
+
+        $result = $service->updateUnresolvedMatches('2026-07-19', [
+            ['match_no' => '', 'home_team' => 'France', 'away_team' => 'Brazil'],
+            ['match_no' => 104, 'home_team' => '', 'away_team' => 'Brazil'],
+            ['match_no' => 104, 'home_team' => 'France', 'away_team' => 'Brazil', 'status' => 'open'],
+        ], 'admin-01');
+
+        $this->assertFalse($result['success']);
+        $this->assertSame(0, $result['data']['updated']);
+        $this->assertCount(3, $result['data']['errors']);
+    }
+
+    public function test_rejects_invalid_schedule_date()
+    {
+        $repository = new InMemoryWorldCupScheduleRepository();
+        $service = new WorldCupScheduleUpdateService($repository);
+
+        $result = $service->updateUnresolvedMatches('2026/07/19', [], 'admin-01');
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('Invalid schedule date', $result['message']);
+    }
+
+    public function test_imports_existing_matches_from_csv_rows_by_match_id()
+    {
+        $repository = new InMemoryWorldCupScheduleRepository();
+        $service = new WorldCupScheduleUpdateService($repository);
+
+        $result = $service->importExistingMatches([
+            [
+                'match_id' => '104',
+                'home_name' => 'France',
+                'away_name' => 'Brazil',
+            ],
+            [
+                'match_id' => '103',
+                'home_name' => 'Argentina',
+                'away_name' => 'England',
+            ],
+            [
+                'match_id' => '',
+                'home_name' => 'Spain',
+                'away_name' => 'Portugal',
+            ],
+        ], 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame(2, $result['data']['updated']);
+        $this->assertSame(0, $result['data']['skipped']);
+        $this->assertCount(1, $result['data']['errors']);
+        $this->assertSame('France', $repository->matches[104]['home_team']);
+        $this->assertSame('Brazil', $repository->matches[104]['away_team']);
+        $this->assertSame('schedule_import', $repository->audits[0]['action']);
+    }
+}
+
+class InMemoryWorldCupScheduleRepository implements WorldCupScheduleRepositoryInterface
+{
+    public $matches = [
+        72 => [
+            'match_no' => 72,
+            'home_team' => 'Croatia',
+            'away_team' => 'Ghana',
+            'venue' => 'Philadelphia',
+            'kickoff_at' => '2026-06-27 21:00:00',
+            'status' => 'scheduled',
+        ],
+        103 => [
+            'match_no' => 103,
+            'home_team' => 'Winner 103 A',
+            'away_team' => 'Winner 103 B',
+            'venue' => 'Miami Gardens',
+            'kickoff_at' => '2026-07-18 21:00:00',
+            'status' => 'closed',
+        ],
+        104 => [
+            'match_no' => 104,
+            'home_team' => 'Winner 104 A',
+            'away_team' => 'Winner 104 B',
+            'venue' => 'East Rutherford',
+            'kickoff_at' => '2026-07-19 19:00:00',
+            'status' => 'closed',
+        ],
+    ];
+
+    public $audits = [];
+
+    public function unresolvedMatchesByDate(string $scheduleDate): array
+    {
+        return array_values(array_filter($this->matches, function (array $match) use ($scheduleDate) {
+            return substr($match['kickoff_at'], 0, 10) === $scheduleDate
+                && $this->isUnresolved($match);
+        }));
+    }
+
+    public function allMatches(): array
+    {
+        ksort($this->matches);
+
+        return array_values($this->matches);
+    }
+
+    public function matchesByDate(string $scheduleDate): array
+    {
+        return array_values(array_filter($this->matches, function (array $match) use ($scheduleDate) {
+            return substr($match['kickoff_at'], 0, 10) === $scheduleDate;
+        }));
+    }
+
+    public function updateMatchByNoAndDate(
+        int $matchNo,
+        string $scheduleDate,
+        array $attributes
+    ): bool {
+        if (!isset($this->matches[$matchNo])) {
+            return false;
+        }
+
+        if (substr($this->matches[$matchNo]['kickoff_at'], 0, 10) !== $scheduleDate) {
+            return false;
+        }
+
+        if (!$this->isUnresolved($this->matches[$matchNo])) {
+            return false;
+        }
+
+        $this->matches[$matchNo] = array_merge($this->matches[$matchNo], $attributes);
+
+        return true;
+    }
+
+    public function updateMatchByNo(int $matchNo, array $attributes): bool
+    {
+        if (!isset($this->matches[$matchNo])) {
+            return false;
+        }
+
+        $this->matches[$matchNo] = array_merge($this->matches[$matchNo], $attributes);
+
+        return true;
+    }
+
+    public function updateMatchById(int $matchId, array $attributes): bool
+    {
+        return $this->updateMatchByNo($matchId, $attributes);
+    }
+
+    public function writeScheduleAudit(string $actor, string $action, array $payload): void
+    {
+        $this->audits[] = compact('actor', 'action', 'payload');
+    }
+
+    private function isUnresolved(array $match): bool
+    {
+        return strpos($match['home_team'], 'Winner ') === 0
+            || strpos($match['away_team'], 'Winner ') === 0;
+    }
+}

+ 19 - 0
tests/Unit/WorldCupSettlementAdminViewTest.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+class WorldCupSettlementAdminViewTest extends TestCase
+{
+    public function testSettlementFormsRequireConfirmBeforeSubmit(): void
+    {
+        $view = file_get_contents(__DIR__ . '/../../resources/views/admin/world_cup/settlement.blade.php');
+
+        $this->assertContains('js-world-cup-settlement-form', $view);
+        $this->assertContains("form.addEventListener('submit'", $view);
+        $this->assertContains('window.confirm', $view);
+        $this->assertContains('event.preventDefault();', $view);
+        $this->assertContains('确认要结算吗?', $view);
+    }
+}

+ 229 - 0
tests/Unit/WorldCupSettlementServiceTest.php

@@ -0,0 +1,229 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\WorldCup\Repositories\WorldCupSettlementRepositoryInterface;
+use App\Services\WorldCup\WorldCupSettlementService;
+use Tests\TestCase;
+
+class WorldCupSettlementServiceTest extends TestCase
+{
+    public function test_settle_match_marks_won_and_lost_bets_and_sends_reward_mail()
+    {
+        $repository = new InMemoryWorldCupSettlementRepository();
+        $service = new WorldCupSettlementService($repository);
+
+        $result = $service->settleMatch(10, 'home', 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame(1, $result['data']['won_count']);
+        $this->assertSame(1, $result['data']['lost_count']);
+        $this->assertSame('finished', $repository->matches[10]['status']);
+        $this->assertSame('home', $repository->matches[10]['result']);
+        $this->assertSame('won', $repository->bets[1]['status']);
+        $this->assertSame('lost', $repository->bets[2]['status']);
+        $this->assertSame(2650, $repository->mails[1001][0]['amount']);
+        $this->assertSame('You won $26.50 · Brazil vs Serbia', $repository->mails[1001][0]['title']);
+        $this->assertStringContainsString('Your bet: Brazil win · $10.00 · Odds 2.15', $repository->mails[1001][0]['text']);
+        $this->assertSame(2650, $result['data']['paid_amount']);
+        $this->assertSame('settle_match', $repository->audits[0]['action']);
+    }
+
+    public function test_settle_match_is_idempotent_after_finished()
+    {
+        $repository = new InMemoryWorldCupSettlementRepository();
+        $service = new WorldCupSettlementService($repository);
+
+        $first = $service->settleMatch(10, 'home', 'admin-01');
+        $second = $service->settleMatch(10, 'home', 'admin-01');
+
+        $this->assertSame(1, $first['data']['won_count']);
+        $this->assertFalse($second['success']);
+        $this->assertSame('Match already settled', $second['message']);
+        $this->assertCount(1, $repository->mails[1001]);
+        $this->assertCount(1, $repository->audits);
+    }
+
+    public function test_settle_match_does_not_override_finished_result()
+    {
+        $repository = new InMemoryWorldCupSettlementRepository();
+        $service = new WorldCupSettlementService($repository);
+
+        $service->settleMatch(10, 'home', 'admin-01');
+        $result = $service->settleMatch(10, 'away', 'admin-01');
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('Match already settled', $result['message']);
+        $this->assertSame('home', $repository->matches[10]['result']);
+        $this->assertSame('won', $repository->bets[1]['status']);
+        $this->assertSame('lost', $repository->bets[2]['status']);
+    }
+
+    public function test_settle_match_rejects_invalid_result()
+    {
+        $service = new WorldCupSettlementService(new InMemoryWorldCupSettlementRepository());
+
+        $result = $service->settleMatch(10, 'Brazil', 'admin-01');
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('Invalid match result', $result['message']);
+    }
+
+    public function test_settle_match_rejects_draw_outside_group_stage()
+    {
+        $repository = new InMemoryWorldCupSettlementRepository();
+        $service = new WorldCupSettlementService($repository);
+
+        $result = $service->settleMatch(11, 'draw', 'admin-01');
+
+        $this->assertFalse($result['success']);
+        $this->assertSame('Draw is only available for group stage', $result['message']);
+        $this->assertSame('scheduled', $repository->matches[11]['status']);
+        $this->assertNull($repository->matches[11]['result']);
+    }
+
+    public function test_settle_winner_market_pays_selected_team()
+    {
+        $repository = new InMemoryWorldCupSettlementRepository();
+        $service = new WorldCupSettlementService($repository);
+
+        $result = $service->settleWinner('Brazil', 'admin-01');
+
+        $this->assertTrue($result['success']);
+        $this->assertSame('won', $repository->bets[3]['status']);
+        $this->assertSame('lost', $repository->bets[4]['status']);
+        $this->assertSame(6500, $repository->mails[1003][0]['amount']);
+        $this->assertSame('You won $65.00 · World Cup 2026 Winner', $repository->mails[1003][0]['title']);
+    }
+}
+
+class InMemoryWorldCupSettlementRepository implements WorldCupSettlementRepositoryInterface
+{
+    public $matches = [
+        10 => [
+            'match_id' => 10,
+            'stage' => 'group',
+            'home_team' => 'Brazil',
+            'away_team' => 'Serbia',
+            'status' => 'scheduled',
+            'result' => null,
+        ],
+        11 => [
+            'match_id' => 11,
+            'stage' => 'round_16',
+            'home_team' => 'Argentina',
+            'away_team' => 'France',
+            'status' => 'scheduled',
+            'result' => null,
+        ],
+    ];
+
+    public $bets = [
+        1 => [
+            'bet_id' => 1,
+            'user_id' => 1001,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'home',
+            'stake' => 1000,
+            'odds' => 2.15,
+            'status' => 'pending',
+            'potential_payout' => 2650,
+        ],
+        2 => [
+            'bet_id' => 2,
+            'user_id' => 1002,
+            'market' => '1x2',
+            'match_id' => 10,
+            'selection' => 'away',
+            'stake' => 1000,
+            'odds' => 1.95,
+            'status' => 'pending',
+            'potential_payout' => 1950,
+        ],
+        3 => [
+            'bet_id' => 3,
+            'user_id' => 1003,
+            'market' => 'winner',
+            'match_id' => null,
+            'selection' => 'Brazil',
+            'stake' => 1000,
+            'odds' => 6.5,
+            'status' => 'pending',
+            'potential_payout' => 6500,
+        ],
+        4 => [
+            'bet_id' => 4,
+            'user_id' => 1004,
+            'market' => 'winner',
+            'match_id' => null,
+            'selection' => 'Argentina',
+            'stake' => 1000,
+            'odds' => 6.0,
+            'status' => 'pending',
+            'potential_payout' => 6000,
+        ],
+    ];
+
+    public $mails = [];
+
+    public $audits = [];
+
+    public function findMatch(int $matchId): ?array
+    {
+        return $this->matches[$matchId] ?? null;
+    }
+
+    public function markMatchFinished(int $matchId, string $result): void
+    {
+        $this->matches[$matchId]['status'] = 'finished';
+        $this->matches[$matchId]['result'] = $result;
+    }
+
+    public function pendingBetsForMatch(int $matchId): array
+    {
+        return array_values(array_map(function (array $bet) use ($matchId) {
+            return array_merge($this->matches[$matchId], $bet);
+        }, array_filter($this->bets, function (array $bet) use ($matchId) {
+            return $bet['market'] === '1x2'
+                && (int)$bet['match_id'] === $matchId
+                && $bet['status'] === 'pending';
+        })));
+    }
+
+    public function pendingWinnerBets(): array
+    {
+        return array_values(array_filter($this->bets, function (array $bet) {
+            return $bet['market'] === 'winner' && $bet['status'] === 'pending';
+        }));
+    }
+
+    public function markBetSettled(int $betId, string $status): void
+    {
+        $this->bets[$betId]['status'] = $status;
+    }
+
+    public function payBet(array $bet): int
+    {
+        $odds = (float)$bet['odds'];
+        $payout = (int)$bet['potential_payout'];
+        $title = $bet['market'] === 'winner'
+            ? 'You won $65.00 · World Cup 2026 Winner'
+            : 'You won $26.50 · Brazil vs Serbia';
+        $text = $bet['market'] === 'winner'
+            ? 'Your bet: Brazil to win · $10.00 · Odds ' . number_format($odds, 2)
+            : 'Your bet: Brazil win · $10.00 · Odds ' . number_format($odds, 2);
+        $this->mails[$bet['user_id']][] = [
+            'title' => $title,
+            'text' => $text,
+            'amount' => $payout,
+        ];
+
+        return $payout;
+    }
+
+    public function writeAudit(string $actor, string $action, array $payload): void
+    {
+        $this->audits[] = compact('actor', 'action', 'payload');
+    }
+}