diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php index 5928ea5..931bd98 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php @@ -5,8 +5,9 @@ namespace App\Http\Controllers\Api\V1\Admin\Player; use App\Models\Player; use App\Models\TicketItem; use App\Support\ApiResponse; -use App\Support\PaginationTrait; use App\Support\CurrencyFormatter; +use App\Support\PaginationTrait; +use App\Support\TicketItemListFilters; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\AdminPlayerTicketItemsRequest; @@ -19,6 +20,7 @@ use App\Http\Requests\Admin\AdminPlayerTicketItemsRequest; final class AdminPlayerTicketItemsIndexController extends Controller { use PaginationTrait; + use TicketItemListFilters; public function __invoke(AdminPlayerTicketItemsRequest $request, Player $player): JsonResponse { @@ -56,22 +58,8 @@ final class AdminPlayerTicketItemsIndexController extends Controller $query->whereIn('ticket_items.status', $statusValues); } - if ($number !== '') { - $query->where(function ($q) use ($number): void { - $q->where('ticket_items.original_number', 'like', '%'.$number.'%') - ->orWhere('ticket_items.normalized_number', 'like', '%'.$number.'%') - ->orWhere('ticket_items.ticket_no', 'like', '%'.$number.'%') - ->orWhereHas('order', fn ($order) => $order->where('order_no', 'like', '%'.$number.'%')); - }); - } - - if (is_string($startDate) && $startDate !== '') { - $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '>=', $startDate)); - } - - if (is_string($endDate) && $endDate !== '') { - $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '<=', $endDate)); - } + $this->applyTicketItemNumberSearch($query, $number); + $this->applyOrderPlacedDateRange($query, $startDate, $endDate); $paginator = $query->paginate(perPage: $perPage, page: $page, columns: ['*']); diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php index f179323..a044c11 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php @@ -66,12 +66,17 @@ final class AdminRiskPoolManualStatusController extends Controller } $targetStatus = $soldOut ? 1 : 0; - if ((int) $pool->sold_out_status !== $targetStatus) { + $soldOutBefore = (int) $pool->sold_out_status; + if ($soldOutBefore !== $targetStatus) { $pool->forceFill([ 'sold_out_status' => $targetStatus, 'version' => (int) $pool->version + 1, ])->save(); + if ($targetStatus === 1) { + $this->riskPoolService->publishManualSoldOut($draw, $number4d); + } + RiskPoolLockLog::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => $number4d, diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php index 993aaca..def278d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php @@ -8,6 +8,7 @@ use App\Models\TicketItem; use App\Support\ApiResponse; use App\Support\CurrencyFormatter; use App\Support\PaginationTrait; +use App\Support\TicketItemListFilters; use Illuminate\Http\JsonResponse; /** @@ -25,6 +26,7 @@ use Illuminate\Http\JsonResponse; final class AdminTicketItemIndexController extends Controller { use PaginationTrait; + use TicketItemListFilters; public function __invoke(TicketItemListRequest $request): JsonResponse { @@ -72,24 +74,12 @@ final class AdminTicketItemIndexController extends Controller } $number = trim((string) ($validated['number'] ?? '')); - if ($number !== '') { - $query->where(function ($q) use ($number): void { - $q->where('ticket_items.original_number', 'like', '%'.$number.'%') - ->orWhere('ticket_items.normalized_number', 'like', '%'.$number.'%') - ->orWhere('ticket_items.ticket_no', 'like', '%'.$number.'%') - ->orWhereHas('order', fn ($order) => $order->where('order_no', 'like', '%'.$number.'%')); - }); - } - - $startDate = $validated['start_date'] ?? null; - if (is_string($startDate) && $startDate !== '') { - $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '>=', $startDate)); - } - - $endDate = $validated['end_date'] ?? null; - if (is_string($endDate) && $endDate !== '') { - $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '<=', $endDate)); - } + $this->applyTicketItemNumberSearch($query, $number); + $this->applyOrderPlacedDateRange( + $query, + is_string($validated['start_date'] ?? null) ? $validated['start_date'] : null, + is_string($validated['end_date'] ?? null) ? $validated['end_date'] : null, + ); $paginator = $query->paginate(perPage: $perPage, page: $page, columns: ['*']); diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php index a26a643..ba2eb8b 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1\Ticket; -use Carbon\Carbon; use App\Models\Player; use App\Models\TicketItem; use App\Support\ApiResponse; +use App\Support\TicketItemListFilters; use Illuminate\Http\Request; use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; @@ -18,6 +18,7 @@ use App\Http\Controllers\Controller; final class TicketItemsIndexController extends Controller { use PaginationTrait; + use TicketItemListFilters; public function __invoke(Request $request): JsonResponse { @@ -56,23 +57,8 @@ final class TicketItemsIndexController extends Controller $query->whereIn('ticket_items.status', $statusValues); } - if ($number !== '') { - $query->where(function ($q) use ($number): void { - $q->where('ticket_items.original_number', 'like', '%'.$number.'%') - ->orWhere('ticket_items.normalized_number', 'like', '%'.$number.'%') - ->orWhere('ticket_items.ticket_no', 'like', '%'.$number.'%'); - }); - } - - if ($startDate !== null) { - $fromUtc = $this->scheduleDateStartUtc($startDate); - $query->whereHas('order', fn ($q) => $q->where('created_at', '>=', $fromUtc)); - } - - if ($endDate !== null) { - $toUtc = $this->scheduleDateEndUtc($endDate); - $query->whereHas('order', fn ($q) => $q->where('created_at', '<=', $toUtc)); - } + $this->applyTicketItemNumberSearch($query, $number); + $this->applyOrderPlacedDateRange($query, $startDate, $endDate); $paginator = $query->paginate(perPage: $perPage, page: $page); @@ -122,23 +108,4 @@ final class TicketItemsIndexController extends Controller return $value; } - - private function scheduleTimezone(): string - { - return (string) config('lottery.draw.timezone', 'UTC'); - } - - private function scheduleDateStartUtc(string $ymd): Carbon - { - return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone()) - ->startOfDay() - ->utc(); - } - - private function scheduleDateEndUtc(string $ymd): Carbon - { - return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone()) - ->endOfDay() - ->utc(); - } } diff --git a/app/Services/Ticket/RiskPoolRealtimePublisher.php b/app/Services/Ticket/RiskPoolRealtimePublisher.php new file mode 100644 index 0000000..c72d402 --- /dev/null +++ b/app/Services/Ticket/RiskPoolRealtimePublisher.php @@ -0,0 +1,79 @@ + */ + private array $drawNoById = []; + + public function __construct( + private readonly LotteryHallRealtimeBroadcaster $hallRealtime, + ) {} + + public function publishAfterLock( + int $drawId, + string $normalizedNumber, + int $soldOutStatusBefore, + int $lockedAmountBefore, + int $totalCapBefore, + RiskPool $poolAfter, + ): void { + $drawNo = $this->resolveDrawNo($drawId); + $normalizedNumber = strtoupper(trim($normalizedNumber)); + + $totalCap = (int) $poolAfter->total_cap_amount; + if ($totalCap < 1) { + $totalCap = max(1, $totalCapBefore); + } + + $soldOutAfter = (int) $poolAfter->sold_out_status; + if ($soldOutAfter === 1 && $soldOutStatusBefore !== 1) { + $this->hallRealtime->notifyRiskSoldOut($drawId, $drawNo, $normalizedNumber); + } + + $lockedAfter = (int) $poolAfter->locked_amount; + $usageBefore = $totalCapBefore > 0 ? $lockedAmountBefore / $totalCapBefore : 0.0; + $usageAfter = $totalCap > 0 ? $lockedAfter / $totalCap : 1.0; + + if ($usageAfter >= self::WARNING_RATIO && $usageBefore < self::WARNING_RATIO) { + $this->hallRealtime->notifyRiskWarning( + $drawId, + $drawNo, + $normalizedNumber, + $usageAfter, + ); + } + } + + public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void + { + $this->hallRealtime->notifyRiskSoldOut( + (int) $draw->id, + (string) $draw->draw_no, + strtoupper(trim($normalizedNumber)), + ); + } + + private function resolveDrawNo(int $drawId): string + { + if (isset($this->drawNoById[$drawId])) { + return $this->drawNoById[$drawId]; + } + + $drawNo = Draw::query()->whereKey($drawId)->value('draw_no'); + $resolved = is_string($drawNo) && $drawNo !== '' ? $drawNo : (string) $drawId; + $this->drawNoById[$drawId] = $resolved; + + return $resolved; + } +} diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php index 39a1936..b68713b 100644 --- a/app/Services/Ticket/RiskPoolService.php +++ b/app/Services/Ticket/RiskPoolService.php @@ -2,6 +2,7 @@ namespace App\Services\Ticket; +use App\Models\Draw; use App\Models\RiskPool; use App\Lottery\ErrorCode; use App\Models\TicketItem; @@ -14,6 +15,7 @@ final class RiskPoolService { public function __construct( private readonly PlayCatalogResolver $catalogResolver, + private readonly RiskPoolRealtimePublisher $riskRealtime, ) {} /** @@ -90,6 +92,10 @@ final class RiskPoolService throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); } + $soldOutBefore = (int) $pool->sold_out_status; + $lockedBefore = (int) $pool->locked_amount; + $totalBefore = (int) $pool->total_cap_amount; + $pool->forceFill([ 'locked_amount' => (int) $pool->locked_amount + $amount, 'remaining_amount' => (int) $pool->remaining_amount - $amount, @@ -97,6 +103,15 @@ final class RiskPoolService 'version' => (int) $pool->version + 1, ])->save(); + $this->riskRealtime->publishAfterLock( + $drawId, + $lock['number_4d'], + $soldOutBefore, + $lockedBefore, + $totalBefore, + $pool, + ); + RiskPoolLockLog::query()->create([ 'draw_id' => $drawId, 'normalized_number' => $lock['number_4d'], @@ -172,6 +187,11 @@ final class RiskPoolService } } + public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void + { + $this->riskRealtime->publishManualSoldOut($draw, $normalizedNumber); + } + /** 后台改池或释池后,将 Redis 风控快照与 DB 对齐。 */ public function syncRedisStateFromPool(RiskPool $pool): void { @@ -279,6 +299,10 @@ LUA; throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); } + $soldOutBefore = (int) $pool->sold_out_status; + $lockedBefore = (int) $pool->locked_amount; + $totalBefore = (int) $pool->total_cap_amount; + $pool->forceFill([ 'locked_amount' => (int) $pool->locked_amount + $amount, 'remaining_amount' => (int) $pool->remaining_amount - $amount, @@ -286,6 +310,15 @@ LUA; 'version' => (int) $pool->version + 1, ])->save(); + $this->riskRealtime->publishAfterLock( + $drawId, + $number4d, + $soldOutBefore, + $lockedBefore, + $totalBefore, + $pool, + ); + RiskPoolLockLog::query()->create([ 'draw_id' => $drawId, 'normalized_number' => $number4d, diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index d5862e5..301a76d 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -8,9 +8,13 @@ use App\Lottery\ErrorCode; use App\Models\TicketOrder; use App\Models\PlayerWallet; use App\Exceptions\TicketOperationException; +use App\Services\Wallet\WalletBalanceRealtimeNotifier; final class TicketWalletService { + public function __construct( + private readonly WalletBalanceRealtimeNotifier $balanceRealtime, + ) {} private const TXN_POSTED = 'posted'; private const TXN_DIR_OUT = 2; @@ -71,6 +75,9 @@ final class TicketWalletService 'remark' => null, ]); + $wallet->refresh(); + $this->balanceRealtime->notifyAfterMovement($wallet, -$amountMinor, 'bet_deduct'); + return $after; } @@ -119,6 +126,9 @@ final class TicketWalletService 'idempotent_key' => $idempotentKey, 'remark' => 'post_deduct_confirmation_failed', ]); + + $wallet->refresh(); + $this->balanceRealtime->notifyAfterMovement($wallet, $amount, 'bet_reverse'); } /** @@ -187,6 +197,9 @@ final class TicketWalletService 'idempotent_key' => $idempotentKey, 'remark' => 'manual_jackpot_burst', ]); + + $wallet->refresh(); + $this->balanceRealtime->notifyAfterMovement($wallet, $amountMinor, 'jackpot_manual_payout'); } public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void @@ -243,6 +256,9 @@ final class TicketWalletService 'idempotent_key' => $idempotentKey, 'remark' => null, ]); + + $wallet->refresh(); + $this->balanceRealtime->notifyAfterMovement($wallet, $amountMinor, 'settle_payout'); } private function newTxnNo(): string diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php index 962603a..804a5b6 100644 --- a/app/Services/Wallet/LotteryTransferService.php +++ b/app/Services/Wallet/LotteryTransferService.php @@ -58,6 +58,7 @@ final class LotteryTransferService public function __construct( private readonly MainSiteWalletGateway $mainSite, + private readonly WalletBalanceRealtimeNotifier $balanceRealtime, ) {} /** @@ -841,6 +842,9 @@ final class LotteryTransferService 'remark' => $remark, ]); + $wallet->refresh(); + $this->balanceRealtime->notifyAfterMovement($wallet, $delta, $bizType); + return [ 'before' => $before, 'after' => $after, diff --git a/app/Services/Wallet/WalletBalanceRealtimeNotifier.php b/app/Services/Wallet/WalletBalanceRealtimeNotifier.php new file mode 100644 index 0000000..14cca7e --- /dev/null +++ b/app/Services/Wallet/WalletBalanceRealtimeNotifier.php @@ -0,0 +1,51 @@ +mapBizTypeToReason($bizType); + if ($reason === null) { + return; + } + + $this->playerRealtime->notifyBalanceUpdate( + (int) $wallet->player_id, + (string) $wallet->currency_code, + (int) $wallet->balance, + $changeMinor, + $reason, + ); + } + + private function mapBizTypeToReason(string $bizType): ?string + { + return match ($bizType) { + 'transfer_in' => 'transfer_in', + 'transfer_out' => 'transfer_out', + 'transfer_out_refund', 'bet_reverse', 'reversal' => 'refund', + 'bet_deduct' => 'bet', + 'settle_payout', 'jackpot_manual_payout' => 'prize', + default => null, + }; + } +} diff --git a/app/Support/TicketItemListFilters.php b/app/Support/TicketItemListFilters.php new file mode 100644 index 0000000..375a7a8 --- /dev/null +++ b/app/Support/TicketItemListFilters.php @@ -0,0 +1,59 @@ +where(function (Builder $q) use ($number): void { + $q->where('ticket_items.original_number', 'like', '%'.$number.'%') + ->orWhere('ticket_items.normalized_number', 'like', '%'.$number.'%') + ->orWhere('ticket_items.ticket_no', 'like', '%'.$number.'%') + ->orWhereHas('order', fn (Builder $order) => $order->where('order_no', 'like', '%'.$number.'%')); + }); + } + + protected function applyOrderPlacedDateRange(Builder $query, ?string $startDate, ?string $endDate): void + { + if (is_string($startDate) && $startDate !== '') { + $fromUtc = $this->scheduleDateStartUtc($startDate); + $query->whereHas('order', fn (Builder $q) => $q->where('created_at', '>=', $fromUtc)); + } + + if (is_string($endDate) && $endDate !== '') { + $toUtc = $this->scheduleDateEndUtc($endDate); + $query->whereHas('order', fn (Builder $q) => $q->where('created_at', '<=', $toUtc)); + } + } + + private function scheduleTimezone(): string + { + return (string) config('lottery.draw.timezone', 'UTC'); + } + + private function scheduleDateStartUtc(string $ymd): Carbon + { + return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone()) + ->startOfDay() + ->utc(); + } + + private function scheduleDateEndUtc(string $ymd): Carbon + { + return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone()) + ->endOfDay() + ->utc(); + } +} diff --git a/tests/Feature/PlayerRealtimeBroadcastTest.php b/tests/Feature/PlayerRealtimeBroadcastTest.php new file mode 100644 index 0000000..3d9da68 --- /dev/null +++ b/tests/Feature/PlayerRealtimeBroadcastTest.php @@ -0,0 +1,129 @@ + 'reverb']); + $this->seed(CurrencySeeder::class); +}); + +test('wallet balance notifier dispatches balance update broadcast', function (): void { + Event::fake([BalanceUpdateBroadcast::class]); + + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'ws-p1', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 10_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + app(WalletBalanceRealtimeNotifier::class)->notifyAfterMovement($wallet, -500, 'bet_deduct'); + + Event::assertDispatched( + BalanceUpdateBroadcast::class, + fn (BalanceUpdateBroadcast $event): bool => $event->playerId === $player->id + && $event->currencyCode === 'NPR' + && $event->balanceMinor === 10_000 + && $event->changeMinor === -500 + && $event->reason === 'bet', + ); +}); + +test('risk pool acquire dispatches warning and sold out broadcasts', function (): void { + Event::fake([RiskWarningBroadcast::class, RiskSoldOutBroadcast::class]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260526-001', + 'business_date' => '2026-05-26', + 'sequence_no' => 1, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 1000, + 'locked_amount' => 750, + 'remaining_amount' => 250, + 'sold_out_status' => 0, + 'version' => 0, + ]); + + app(RiskPoolService::class)->acquire($draw->id, null, [ + ['number_4d' => '1234', 'amount' => 250], + ]); + + Event::assertDispatched( + RiskWarningBroadcast::class, + fn (RiskWarningBroadcast $event): bool => $event->drawId === $draw->id + && $event->drawNo === '20260526-001' + && $event->normalizedNumber === '1234', + ); + + Event::assertDispatched( + RiskSoldOutBroadcast::class, + fn (RiskSoldOutBroadcast $event): bool => $event->drawId === $draw->id + && $event->normalizedNumber === '1234', + ); +}); + +test('transfer in dispatches balance update after success', function (): void { + Event::fake([BalanceUpdateBroadcast::class]); + + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'ws-p2', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + app(LotteryTransferService::class)->transferIn($player, 'NPR', 500, 'idem-ws-'.uniqid('', true)); + + Event::assertDispatched(BalanceUpdateBroadcast::class); +}); diff --git a/tests/Feature/TicketItemsApiTest.php b/tests/Feature/TicketItemsApiTest.php index e2ceee6..5f1c0d6 100644 --- a/tests/Feature/TicketItemsApiTest.php +++ b/tests/Feature/TicketItemsApiTest.php @@ -104,17 +104,20 @@ function ticketItemsPublishAndSettle(Draw $draw, string $firstNumber): void } test('jackpot summary is public', function (): void { - JackpotPool::query()->create([ - 'currency_code' => 'NPR', - 'current_amount' => 1_234_000, - 'contribution_rate' => '0.0100', - 'trigger_threshold' => 0, - 'payout_rate' => '0.5000', - 'force_trigger_draw_gap' => 0, - 'min_bet_amount' => 0, - 'status' => 1, - 'last_trigger_draw_id' => null, - ]); + // 迁移 seed_default_jackpot_pools 已插入 NPR 池,须 upsert 避免 UNIQUE(currency_code) + JackpotPool::query()->updateOrCreate( + ['currency_code' => 'NPR'], + [ + 'current_amount' => 1_234_000, + 'contribution_rate' => '0.0100', + 'trigger_threshold' => 0, + 'payout_rate' => '0.5000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ], + ); $this->getJson('/api/v1/jackpot/summary?currency_code=NPR') ->assertOk()