diff --git a/.env.example b/.env.example index ea2ccf0..bc206ee 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,9 @@ LOTTERY_DRAW_INTERVAL_MINUTES=5 LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270 LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30 LOTTERY_DRAW_BUFFER_AHEAD=8 +# 自动派彩失败后按 60s、120s、240s... 指数退避,最长每小时重试一次 +LOTTERY_AUTO_PAYOUT_RETRY_BASE_SECONDS=60 +LOTTERY_AUTO_PAYOUT_RETRY_MAX_SECONDS=3600 LOTTERY_PLAYER_AUTH_DEV_BYPASS=false ADMIN_API_TOKEN_TTL_DAYS=7 diff --git a/app/Events/PlayerSessionReplacedBroadcast.php b/app/Events/PlayerSessionReplacedBroadcast.php new file mode 100644 index 0000000..3557ffd --- /dev/null +++ b/app/Events/PlayerSessionReplacedBroadcast.php @@ -0,0 +1,53 @@ +addSeconds(30); + } + + /** @return array */ + public function broadcastOn(): array + { + return [new PrivateChannel('player.'.$this->playerId)]; + } + + public function broadcastAs(): string + { + return 'session.replaced'; + } + + /** @return array{player_id: int, session_version: int, emitted_at_ms: int} */ + public function broadcastWith(): array + { + return [ + 'player_id' => $this->playerId, + 'session_version' => $this->sessionVersion, + 'emitted_at_ms' => $this->emittedAtMs, + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php index a384c90..2e34036 100644 --- a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php +++ b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use App\Support\ApiResponse; use App\Support\AdminAuthProfile; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use Illuminate\Support\Facades\Hash; use App\Services\AdminCaptchaService; @@ -37,38 +38,59 @@ final class LoginController extends Controller $normalizedAccount = Str::lower(trim($data['account'])); - /** @var AdminUser|null $admin */ - $admin = AdminUser::query()->where('username', $normalizedAccount)->first(); - - $passwordOk = $admin !== null && Hash::check($data['password'], $admin->password); - - if (! $passwordOk) { - /** 统一措辞,弱化枚举用户 */ - return ApiResponse::error( - trans('admin.invalid_credentials', [], $locale), - ErrorCode::AdminCredentialsInvalid->value, - null, - 401, - ); - } - - if ((int) $admin->status !== 0) { - return ApiResponse::error( - trans('admin.account_disabled', [], $locale), - ErrorCode::AdminAccountDisabled->value, - null, - 403, - ); - } - $ttlDays = (int) config('lottery.admin_api.token_ttl_days', 7); - $plainToken = $admin->createToken( - 'admin-api', - ['*'], - now()->addDays(max(1, $ttlDays)), - )->plainTextToken; - $admin->forceFill(['last_login_at' => now()])->save(); + /** + * 串行化同账号登录,确保并发登录也只有版本号最大的会话有效。 + * 不立即删除旧 Token,旧端下一次请求时才能收到明确的 8115 业务码。 + */ + $loginResult = DB::transaction(function () use ($normalizedAccount, $data, $ttlDays, $locale): array|JsonResponse { + /** @var AdminUser|null $lockedAdmin */ + $lockedAdmin = AdminUser::query() + ->where('username', $normalizedAccount) + ->lockForUpdate() + ->first(); + + if ($lockedAdmin === null || ! Hash::check($data['password'], $lockedAdmin->password)) { + /** 统一措辞,弱化枚举用户 */ + return ApiResponse::error( + trans('admin.invalid_credentials', [], $locale), + ErrorCode::AdminCredentialsInvalid->value, + null, + 401, + ); + } + + if ((int) $lockedAdmin->status !== 0) { + return ApiResponse::error( + trans('admin.account_disabled', [], $locale), + ErrorCode::AdminAccountDisabled->value, + null, + 403, + ); + } + + $sessionVersion = (int) $lockedAdmin->admin_session_version + 1; + $lockedAdmin->forceFill([ + 'admin_session_version' => $sessionVersion, + 'last_login_at' => now(), + ])->save(); + + $token = $lockedAdmin->createToken( + 'admin-api', + ['admin-session:'.$sessionVersion], + now()->addDays(max(1, $ttlDays)), + )->plainTextToken; + + return [$token, $lockedAdmin]; + }); + + if ($loginResult instanceof JsonResponse) { + return $loginResult; + } + + [$plainToken, $admin] = $loginResult; + return ApiResponse::success([ 'token' => $plainToken, 'token_type' => 'Bearer', diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/LogoutController.php b/app/Http/Controllers/Api/V1/Admin/Auth/LogoutController.php new file mode 100644 index 0000000..638e3b6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Auth/LogoutController.php @@ -0,0 +1,20 @@ +lotteryAdmin()->tokens() + ->where('name', 'admin-api') + ->delete(); + + return ApiResponse::success(['logged_out' => true]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php index 261e123..6495f07 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php @@ -3,17 +3,18 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; use App\Models\Draw; +use App\Models\AdminUser; +use App\Lottery\ErrorCode; use App\Models\TicketItem; use App\Models\TicketOrder; -use App\Models\AdminUser; +use App\Support\ApiMessage; use App\Support\ApiResponse; +use Illuminate\Http\Request; use App\Models\SettlementBatch; +use App\Support\AdminScopePolicy; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; -use App\Lottery\ErrorCode; use App\Support\AdminDrawResponsePolicy; -use App\Support\AdminScopePolicy; -use App\Support\ApiMessage; /** * GET /api/v1/admin/draws/{draw}/finance-summary — 单期投注/派彩汇总(客服/财务视角,PRD §15.4)。 @@ -22,7 +23,7 @@ use App\Support\ApiMessage; */ final class AdminDrawFinanceSummaryController extends Controller { - public function __invoke(\Illuminate\Http\Request $request, Draw $draw): JsonResponse + public function __invoke(Request $request, Draw $draw): JsonResponse { $admin = $request->lotteryAdmin(); abort_if(! $admin instanceof AdminUser, 401); @@ -58,14 +59,19 @@ final class AdminDrawFinanceSummaryController extends Controller $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; $batches = SettlementBatch::query() + ->with('resultBatch:id,provider_code,provider_name,result_version') ->where('draw_id', $drawId) ->orderByDesc('id') ->limit(30) - ->get(['id', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']); + ->get(['id', 'result_batch_id', 'settle_version', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']); $batchRows = $batches->map(static function (SettlementBatch $b): array { return [ 'id' => (int) $b->id, + 'provider_code' => $b->resultBatch?->provider_code, + 'provider_name' => $b->resultBatch?->provider_name, + 'result_version' => $b->resultBatch !== null ? (int) $b->resultBatch->result_version : null, + 'settle_version' => (int) $b->settle_version, 'status' => $b->status, 'total_ticket_count' => (int) $b->total_ticket_count, 'total_win_count' => (int) $b->total_win_count, diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawSettlementRunController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawSettlementRunController.php index 0707a40..4a1b539 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/DrawSettlementRunController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawSettlementRunController.php @@ -5,18 +5,21 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; use App\Models\Draw; use App\Models\AdminUser; use App\Lottery\ErrorCode; +use App\Support\ApiMessage; use App\Support\ApiResponse; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Services\Settlement\SettlementOrchestrator; +use App\Services\Settlement\DrawSettlementStartService; /** - * POST /api/v1/admin/draws/{draw}/settlement/run — 对 `settling` 期号执行结算(可关自动结算时手工触发)。 + * POST /api/v1/admin/draws/{draw}/settlement/run — 冷静期可提前结算,结算中可幂等重试。 */ final class DrawSettlementRunController extends Controller { public function __construct( + private readonly DrawSettlementStartService $startService, private readonly SettlementOrchestrator $orchestrator, ) {} @@ -32,7 +35,13 @@ final class DrawSettlementRunController extends Controller ); } - $ran = $this->orchestrator->trySettleDraw($draw); + try { + $started = $this->startService->start($draw); + } catch (\RuntimeException $e) { + return ApiMessage::runtimeErrorResponse($request, $e); + } + + $ran = $this->orchestrator->trySettleDraw($started['draw']); $draw->refresh(); @@ -44,6 +53,8 @@ final class DrawSettlementRunController extends Controller 'draw_no' => $draw->draw_no, 'status' => $draw->status, 'settle_version' => (int) $draw->settle_version, + 'cooldown_skipped' => $started['cooldown_skipped'], + 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), ], 409, ); @@ -54,6 +65,8 @@ final class DrawSettlementRunController extends Controller 'draw_no' => $draw->draw_no, 'status' => $draw->status, 'settle_version' => (int) $draw->settle_version, + 'cooldown_skipped' => $started['cooldown_skipped'], + 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php index 03153f3..b32c51b 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -5,12 +5,12 @@ namespace App\Http\Controllers\Api\V1\Ticket; 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; use App\Support\CurrencyFormatter; use App\Http\Controllers\Controller; +use App\Support\TicketItemListFilters; /** * `GET /api/v1/ticket/items` — 我的注单(注项列表,支持 `draw_no` 筛选)。 @@ -37,6 +37,7 @@ final class TicketItemsIndexController extends Controller $statusInput, ))) : []; $number = trim((string) $request->query('number', '')); + $ticketNo = trim((string) $request->query('ticket_no', '')); $orderNo = trim((string) $request->query('order_no', '')); $startDate = $this->normalizeDate((string) $request->query('start_date', '')); $endDate = $this->normalizeDate((string) $request->query('end_date', '')); @@ -62,6 +63,10 @@ final class TicketItemsIndexController extends Controller $query->whereHas('order', fn ($q) => $q->where('order_no', $orderNo)); } + if ($ticketNo !== '') { + $query->where('ticket_items.ticket_no', $ticketNo); + } + $this->applyTicketItemNumberSearch($query, $number); $this->applyOrderPlacedDateRange($query, $startDate, $endDate); diff --git a/app/Http/Middleware/EnsureAdminApi.php b/app/Http/Middleware/EnsureAdminApi.php index 1555fe8..3d1f211 100644 --- a/app/Http/Middleware/EnsureAdminApi.php +++ b/app/Http/Middleware/EnsureAdminApi.php @@ -7,6 +7,7 @@ use App\Models\AdminUser; use App\Lottery\ErrorCode; use App\Support\ApiResponse; use Illuminate\Http\Request; +use Laravel\Sanctum\PersonalAccessToken; use Symfony\Component\HttpFoundation\Response; /** @@ -37,6 +38,25 @@ final class EnsureAdminApi ); } + $accessToken = $user->currentAccessToken(); + if ( + (int) $user->admin_session_version > 0 + && $accessToken instanceof PersonalAccessToken + && $accessToken->name === 'admin-api' + ) { + $expectedAbility = 'admin-session:'.(int) $user->admin_session_version; + $abilities = is_array($accessToken->abilities) ? $accessToken->abilities : []; + + if (! in_array($expectedAbility, $abilities, true)) { + return ApiResponse::error( + trans('admin.session_replaced', [], $request->lotteryLocale()), + ErrorCode::AdminSessionReplaced->value, + null, + 401, + ); + } + } + $request->attributes->set('lottery_admin', $user); return $next($request); diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index 27c1932..0094f73 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -127,6 +127,9 @@ enum ErrorCode: int /** 原生登录:验证码错误或过期 */ case PlayerCaptchaInvalid = 8009; + /** 原生登录:当前会话已被同账号的较新登录替换 */ + case PlayerSessionReplaced = 8010; + /* ========== 8100–8199 管理端 API ========== */ /** 未登录或 Token 无效 */ @@ -144,6 +147,9 @@ enum ErrorCode: int /** 已登录但无 RBAC 权限 */ case AdminForbidden = 8114; + /** 当前管理端会话已被同账号的较新登录替换 */ + case AdminSessionReplaced = 8115; + /* ========== 9000–9999 系统 / 框架 ========== */ /** 表单或 Query 校验失败(ValidationException → 422) */ diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 7193e11..78d3f23 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -30,6 +30,7 @@ final class AdminUser extends Authenticatable 'password', 'status', 'is_super_admin', + 'admin_session_version', ]; protected $hidden = [ @@ -44,6 +45,7 @@ final class AdminUser extends Authenticatable 'last_login_at' => 'datetime', 'password' => 'hashed', 'is_super_admin' => 'boolean', + 'admin_session_version' => 'integer', ]; } diff --git a/app/Models/Player.php b/app/Models/Player.php index 1de41c3..1e040b0 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -28,6 +28,7 @@ final class Player extends Model 'login_failed_count', 'login_locked_until', 'native_token_version', + 'native_session_version', ]; protected $hidden = [ @@ -42,6 +43,7 @@ final class Player extends Model 'login_failed_count' => 'integer', 'login_locked_until' => 'datetime', 'native_token_version' => 'integer', + 'native_session_version' => 'integer', 'risk_tags' => 'array', ]; } diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index a0d0926..fe3d753 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -100,7 +100,7 @@ final class PlayerCreditService } } - public function holdForBet(Player $player, int $amountMinor): void + public function holdForBet(Player $player, int $amountMinor, ?int $ticketOrderId = null): void { if ($amountMinor <= 0) { return; @@ -155,8 +155,8 @@ final class PlayerCreditService 'owner_id' => $player->id, 'amount' => -$amountMinor, 'reason' => 'bet_hold', - 'ref_type' => 'bet', - 'ref_id' => null, + 'ref_type' => $ticketOrderId !== null && $ticketOrderId > 0 ? 'ticket_order' : 'bet', + 'ref_id' => $ticketOrderId !== null && $ticketOrderId > 0 ? $ticketOrderId : null, 'created_at' => $now, 'updated_at' => $now, ]); @@ -289,14 +289,14 @@ final class PlayerCreditService } } - public function assertMayPlaceBet(Player $player, int $amountMinor): void + public function assertMayPlaceBet(Player $player, int $amountMinor, ?int $ticketOrderId = null): void { if (! PlayerFundingMode::usesCredit($player)) { return; } $this->assertCreditGuards($player); - $this->holdForBet($player, $amountMinor); + $this->holdForBet($player, $amountMinor, $ticketOrderId); } private function assertCreditGuards(Player $player): void diff --git a/app/Services/Player/PlayerNativeAuthService.php b/app/Services/Player/PlayerNativeAuthService.php index eac1f1b..453def0 100644 --- a/app/Services/Player/PlayerNativeAuthService.php +++ b/app/Services/Player/PlayerNativeAuthService.php @@ -6,7 +6,9 @@ use Firebase\JWT\JWT; use App\Models\Player; use App\Lottery\ErrorCode; use App\Support\PlayerAuthSource; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use App\Events\PlayerSessionReplacedBroadcast; use App\Exceptions\PlayerAuthenticationException; final class PlayerNativeAuthService @@ -63,17 +65,62 @@ final class PlayerNativeAuthService ); } - $player->forceFill([ - 'login_failed_count' => 0, - 'login_locked_until' => null, - 'last_login_at' => now(), - ])->save(); - $ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800); - $token = $this->issueToken($player, $ttl); + + /** @var array{token: string, player: Player, session_version: int} $login */ + $login = DB::transaction(function () use ($player, $password, $ttl): array { + $locked = Player::query()->lockForUpdate()->find($player->id); + if ($locked === null + || ! $locked->isLotteryNative() + || ! is_string($locked->password_hash) + || ! Hash::check($password, $locked->password_hash)) { + throw new PlayerAuthenticationException( + '账号或密码错误', + ErrorCode::PlayerCredentialsInvalid->value, + ); + } + + if ($locked->login_locked_until !== null && $locked->login_locked_until->isFuture()) { + throw new PlayerAuthenticationException( + '登录已锁定', + ErrorCode::PlayerLoginLocked->value, + 403, + ); + } + + if ((int) $locked->status !== 0) { + throw new PlayerAuthenticationException( + '账号已冻结', + ErrorCode::PlayerAccountSuspended->value, + 403, + ); + } + + $sessionVersion = (int) ($locked->native_session_version ?? 0) + 1; + $locked->forceFill([ + 'login_failed_count' => 0, + 'login_locked_until' => null, + 'last_login_at' => now(), + 'native_session_version' => $sessionVersion, + ])->save(); + + return [ + 'token' => $this->issueToken($locked, $ttl), + 'player' => $locked->refresh(), + 'session_version' => $sessionVersion, + ]; + }); + + event(new PlayerSessionReplacedBroadcast( + (int) $login['player']->id, + $login['session_version'], + (int) floor(microtime(true) * 1000), + )); + + $player = $login['player']; return [ - 'access_token' => $token, + 'access_token' => $login['token'], 'expires_in' => $ttl, 'token_type' => 'Bearer', 'player' => [ @@ -100,6 +147,7 @@ final class PlayerNativeAuthService $playerIdKey => (int) $player->id, $authKey => PlayerAuthSource::LOTTERY_NATIVE, 'token_version' => (int) ($player->native_token_version ?? 0), + 'session_version' => (int) ($player->native_session_version ?? 0), 'site_code' => (string) $player->site_code, 'iat' => $now, 'exp' => $now + $ttl, diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 7b7a6f8..c5c619e 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -192,6 +192,14 @@ final class PlayerTokenResolver throw new PlayerAuthenticationException('Token 已因密码变更失效', ErrorCode::PlayerTokenInvalid->value); } + $sessionVersion = (int) data_get($claims, 'session_version', 0); + if ($sessionVersion !== (int) ($player->native_session_version ?? 0)) { + throw new PlayerAuthenticationException( + '当前会话已被同账号的较新登录替换', + ErrorCode::PlayerSessionReplaced->value, + ); + } + $player->forceFill(['last_login_at' => now()])->save(); return $player->refresh(); diff --git a/app/Services/Settlement/DrawSettlementStartService.php b/app/Services/Settlement/DrawSettlementStartService.php new file mode 100644 index 0000000..969da6d --- /dev/null +++ b/app/Services/Settlement/DrawSettlementStartService.php @@ -0,0 +1,57 @@ +whereKey($draw->id)->lockForUpdate()->firstOrFail(); + + if (! in_array($locked->status, [ + DrawStatus::Cooldown->value, + DrawStatus::Settling->value, + ], true)) { + throw new \RuntimeException('draw_not_ready_for_settlement'); + } + + $hasPublishedResult = DrawResultBatch::query() + ->where('draw_id', $locked->id) + ->where('status', DrawResultBatchStatus::Published->value) + ->exists(); + + if (! $hasPublishedResult) { + throw new \RuntimeException('draw_result_not_published'); + } + + $cooldownSkipped = $locked->status === DrawStatus::Cooldown->value; + if ($cooldownSkipped) { + $locked->forceFill([ + 'status' => DrawStatus::Settling->value, + 'cooling_end_time' => now(), + ])->save(); + } + + return [ + 'draw' => $locked->fresh(), + 'cooldown_skipped' => $cooldownSkipped, + ]; + }); + } +} diff --git a/app/Services/Settlement/SettlementTickFinalizer.php b/app/Services/Settlement/SettlementTickFinalizer.php index ff454db..37962a0 100644 --- a/app/Services/Settlement/SettlementTickFinalizer.php +++ b/app/Services/Settlement/SettlementTickFinalizer.php @@ -3,6 +3,7 @@ namespace App\Services\Settlement; use App\Services\AuditLogger; +use Illuminate\Support\Carbon; use App\Models\SettlementBatch; use App\Services\LotterySettings; use Illuminate\Support\Facades\DB; @@ -23,19 +24,21 @@ final class SettlementTickFinalizer $approved = 0; $paid = 0; $payoutFailed = 0; + $finalizeLimit = (int) config('lottery.draw_tick_finalize_limit', 5); $autoApprove = (bool) LotterySettings::get('settlement.auto_approve_on_tick', true); $pending = $autoApprove ? SettlementBatch::query() ->where('status', SettlementBatchStatus::PendingReview->value) ->orderBy('id') - ->limit((int) config('lottery.draw_tick_finalize_limit', 5)) + ->limit($finalizeLimit) ->get() : collect(); // 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。 // 这样进程即使在 approve 提交后、payout 开始前退出,下一 tick 仍能续跑。 - $approvedDrawIds = DB::table('settlement_batches as approved') + $retryBaseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60); + $approvedDrawCandidates = DB::table('settlement_batches as approved') ->where('approved.status', SettlementBatchStatus::Approved->value) ->whereNotExists(function ($query): void { $query->selectRaw('1') @@ -47,10 +50,21 @@ final class SettlementTickFinalizer ]); }) ->groupBy('approved.draw_id') + ->selectRaw('approved.draw_id, MAX(approved.auto_payout_attempts) AS max_auto_payout_attempts, MAX(approved.updated_at) AS last_attempt_at') + ->havingRaw( + '(MAX(approved.auto_payout_attempts) = 0 OR MAX(approved.updated_at) <= ?)', + [now()->subSeconds($retryBaseSeconds)], + ) ->orderByRaw('MAX(approved.auto_payout_attempts)') ->orderByRaw('MIN(approved.id)') - ->limit((int) config('lottery.draw_tick_finalize_limit', 5)) - ->pluck('approved.draw_id'); + ->cursor(); + $approvedDrawIds = $approvedDrawCandidates + ->filter(fn (object $candidate): bool => $this->autoPayoutRetryIsDue( + (int) $candidate->max_auto_payout_attempts, + $candidate->last_attempt_at !== null ? (string) $candidate->last_attempt_at : null, + )) + ->take($finalizeLimit) + ->pluck('draw_id'); $candidateDrawIds = $pending->pluck('draw_id') ->merge($approvedDrawIds) ->map(fn ($id): int => (int) $id) @@ -139,4 +153,20 @@ final class SettlementTickFinalizer ); }); } + + private function autoPayoutRetryIsDue(int $attempts, ?string $lastAttemptAt): bool + { + if ($attempts <= 0 || $lastAttemptAt === null) { + return true; + } + + $baseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60); + $maxSeconds = max($baseSeconds, (int) config('lottery.auto_payout_retry_max_seconds', 3600)); + $exponent = min($attempts - 1, 20); + $delaySeconds = min($maxSeconds, $baseSeconds * (2 ** $exponent)); + + return now()->greaterThanOrEqualTo( + Carbon::parse($lastAttemptAt)->addSeconds($delaySeconds), + ); + } } diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index dd654d2..1a83dc4 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -341,7 +341,11 @@ final class TicketPlacementService ])->save(); if ($creditLine) { - $this->playerCreditService->assertMayPlaceBet($player, $successTotalActualDeduct); + $this->playerCreditService->assertMayPlaceBet( + $player, + $successTotalActualDeduct, + (int) $order->id, + ); } else { $this->ticketWalletService->reserveBetDeduct( $player, diff --git a/app/Services/Wallet/PlayerCreditActivityService.php b/app/Services/Wallet/PlayerCreditActivityService.php new file mode 100644 index 0000000..df1c096 --- /dev/null +++ b/app/Services/Wallet/PlayerCreditActivityService.php @@ -0,0 +1,425 @@ +> + */ + public function build(Player $player): array + { + $orders = DB::table('ticket_orders as o') + ->leftJoin('draws as d', 'd.id', '=', 'o.draw_id') + ->where('o.player_id', (int) $player->id) + ->where('o.total_actual_deduct', '>', 0) + ->orderByDesc('o.id') + ->get([ + 'o.id', + 'o.order_no', + 'o.currency_code', + 'o.total_actual_deduct', + 'o.status', + 'o.created_at', + 'o.updated_at', + 'd.draw_no', + ]); + + $orderActivities = $this->orderActivities($player, $orders); + $paymentActivities = $this->paymentActivities($player); + $activities = array_merge($orderActivities, $paymentActivities); + + usort($activities, static function (array $left, array $right): int { + $leftTime = isset($left['created_at']) ? strtotime((string) $left['created_at']) : 0; + $rightTime = isset($right['created_at']) ? strtotime((string) $right['created_at']) : 0; + if ($leftTime === $rightTime) { + return strcmp((string) ($right['log_id'] ?? ''), (string) ($left['log_id'] ?? '')); + } + + return $rightTime <=> $leftTime; + }); + + return $activities; + } + + /** + * @param Collection $orders + * @return list> + */ + private function orderActivities(Player $player, Collection $orders): array + { + if ($orders->isEmpty()) { + return []; + } + + $orderIds = $orders->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + $items = DB::table('ticket_items') + ->whereIn('order_id', $orderIds) + ->where('actual_deduct_amount', '>', 0) + ->orderBy('id') + ->get([ + 'id', + 'order_id', + 'ticket_no', + 'actual_deduct_amount', + 'status', + 'win_amount', + 'jackpot_win_amount', + 'settled_at', + 'updated_at', + ]); + $itemsByOrder = $items->groupBy(static fn (object $item): int => (int) $item->order_id); + $ticketIds = $items->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + + $activeShares = $this->activeShareRows($ticketIds)->keyBy('ticket_item_id'); + $activeRebates = $this->activeRebateTotals($ticketIds); + $availableDeltas = $this->ticketAvailableDeltas((int) $player->id, $ticketIds); + + $activities = []; + foreach ($orders as $order) { + /** @var Collection $orderItems */ + $orderItems = $itemsByOrder->get((int) $order->id, collect()); + if ($orderItems->isEmpty()) { + continue; + } + + $stakeAmount = (int) $orderItems->sum( + static fn (object $item): int => (int) $item->actual_deduct_amount, + ); + $rebateAmount = 0; + $gameWinLoss = 0; + $winAmount = 0; + $availableDelta = 0; + $hasSettlement = false; + $hasPendingPayout = false; + $latestSettlementAt = null; + + foreach ($orderItems as $item) { + $ticketId = (int) $item->id; + $share = $activeShares->get($ticketId); + if ($share !== null) { + $ticketGameWinLoss = (int) $share->game_win_loss; + $gameWinLoss += $ticketGameWinLoss; + $winAmount += max(0, -$ticketGameWinLoss); + $hasSettlement = true; + $latestSettlementAt = $this->latestTimestamp( + $latestSettlementAt, + $share->settled_at ?? $share->created_at ?? null, + ); + } elseif (in_array((string) $item->status, ['settled_win', 'settled_lose', 'pending_payout'], true)) { + $fallbackWin = (int) $item->win_amount + (int) $item->jackpot_win_amount; + $ticketGameWinLoss = (string) $item->status === 'settled_lose' + ? (int) $item->actual_deduct_amount + : -$fallbackWin; + $gameWinLoss += $ticketGameWinLoss; + $winAmount += max(0, -$ticketGameWinLoss); + $hasSettlement = true; + } + + $rebateAmount += (int) ($activeRebates[$ticketId] ?? 0); + $availableDelta += (int) ($availableDeltas[$ticketId] ?? 0); + $hasPendingPayout = $hasPendingPayout || (string) $item->status === 'pending_payout'; + $latestSettlementAt = $this->latestTimestamp( + $latestSettlementAt, + $item->settled_at ?? $item->updated_at ?? null, + ); + } + + $refunded = in_array((string) $order->status, ['refunded', 'cancelled'], true) + || $orderItems->every( + static fn (object $item): bool => in_array((string) $item->status, ['refunded', 'failed'], true), + ); + $ticketCount = $orderItems->count(); + $firstTicketNo = $ticketCount === 1 ? (string) ($orderItems->first()->ticket_no ?? '') : null; + + if ($refunded) { + $activities[] = $this->formatActivity( + logId: 'CA-ORDER-'.$order->id, + type: 'reversal', + bizType: 'bet_refund', + activityKind: 'refund', + activityStatus: 'reversed', + amount: $stakeAmount, + currency: (string) $order->currency_code, + availableDelta: 0, + createdAt: $this->isoTimestamp($order->updated_at ?? $order->created_at ?? null), + order: $order, + ticketCount: $ticketCount, + ticketNo: $firstTicketNo, + stakeAmount: $stakeAmount, + winAmount: 0, + rebateAmount: 0, + ); + + continue; + } + + if ($hasSettlement) { + $netAmount = -$gameWinLoss + $rebateAmount; + $activities[] = $this->formatActivity( + logId: 'CA-ORDER-'.$order->id, + type: 'game_settlement', + bizType: $netAmount > 0 ? 'settled_win' : ($netAmount < 0 ? 'settled_loss' : 'settled_even'), + activityKind: 'draw_result', + activityStatus: $hasPendingPayout ? 'pending' : 'completed', + amount: $netAmount, + currency: (string) $order->currency_code, + availableDelta: $availableDelta, + createdAt: $this->isoTimestamp($latestSettlementAt ?? $order->updated_at ?? null), + order: $order, + ticketCount: $ticketCount, + ticketNo: $firstTicketNo, + stakeAmount: $stakeAmount, + winAmount: $winAmount, + rebateAmount: $rebateAmount, + ); + + continue; + } + + $activities[] = $this->formatActivity( + logId: 'CA-ORDER-'.$order->id, + type: 'bet', + bizType: 'bet_pending', + activityKind: 'bet', + activityStatus: 'pending', + amount: -$stakeAmount, + currency: (string) $order->currency_code, + availableDelta: -$stakeAmount, + createdAt: $this->isoTimestamp($order->created_at ?? null), + order: $order, + ticketCount: $ticketCount, + ticketNo: $firstTicketNo, + stakeAmount: $stakeAmount, + winAmount: 0, + rebateAmount: 0, + ); + } + + return $activities; + } + + /** + * @return list> + */ + private function paymentActivities(Player $player): array + { + $rows = DB::table('payment_records as pr') + ->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id') + ->where('sb.bill_type', 'player') + ->where('sb.owner_type', 'player') + ->where('sb.owner_id', (int) $player->id) + ->where('pr.status', 'confirmed') + ->orderByDesc('pr.id') + ->get([ + 'pr.id', + 'pr.settlement_bill_id', + 'pr.payer_type', + 'pr.payer_id', + 'pr.payee_type', + 'pr.payee_id', + 'pr.amount', + 'pr.confirmed_at', + 'pr.created_at', + ]); + + $activities = []; + foreach ($rows as $row) { + $playerPaid = (string) $row->payer_type === 'player' + && (int) $row->payer_id === (int) $player->id; + $playerReceived = (string) $row->payee_type === 'player' + && (int) $row->payee_id === (int) $player->id; + if (! $playerPaid && ! $playerReceived) { + continue; + } + + $amount = (int) $row->amount * ($playerReceived ? 1 : -1); + $activities[] = [ + 'log_id' => 'CA-PAYMENT-'.$row->id, + 'type' => 'bill_settlement', + 'biz_type' => $playerReceived ? 'period_received' : 'period_paid', + 'activity_kind' => 'period_settlement', + 'activity_status' => 'completed', + 'amount' => $amount, + 'amount_abs' => abs($amount), + 'direction' => $amount >= 0 ? 'in' : 'out', + 'currency_code' => (string) $player->default_currency, + 'balance_after' => null, + 'affects_available_credit' => $playerPaid, + '_available_delta' => $playerPaid ? (int) $row->amount : 0, + 'ref_id' => 'settlement_bill#'.$row->settlement_bill_id, + 'settlement_bill_id' => (int) $row->settlement_bill_id, + 'order_no' => null, + 'draw_no' => null, + 'ticket_no' => null, + 'ticket_count' => 0, + 'stake_amount' => 0, + 'win_amount' => 0, + 'rebate_amount' => 0, + 'net_amount' => $amount, + 'idempotent_key' => null, + 'external_ref_no' => null, + 'status' => 'posted', + 'remark' => null, + 'created_at' => $this->isoTimestamp($row->confirmed_at ?? $row->created_at ?? null), + 'ledger_source' => 'credit_activity', + 'funding_mode' => (string) $player->funding_mode, + 'auth_source' => $player->auth_source, + ]; + } + + return $activities; + } + + /** + * @param list $ticketIds + * @return Collection + */ + private function activeShareRows(array $ticketIds): Collection + { + if ($ticketIds === []) { + return collect(); + } + + return DB::table('share_ledger as sl') + ->whereIn('sl.ticket_item_id', $ticketIds) + ->whereNull('sl.reversal_of_id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('share_ledger as reversal') + ->whereColumn('reversal.reversal_of_id', 'sl.id'); + }) + ->get(['sl.ticket_item_id', 'sl.game_win_loss', 'sl.settled_at', 'sl.created_at']); + } + + /** + * @param list $ticketIds + * @return array + */ + private function activeRebateTotals(array $ticketIds): array + { + if ($ticketIds === []) { + return []; + } + + return DB::table('rebate_records as rr') + ->whereIn('rr.ticket_item_id', $ticketIds) + ->whereNull('rr.reversal_of_id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('rebate_records as reversal') + ->whereColumn('reversal.reversal_of_id', 'rr.id'); + }) + ->selectRaw('rr.ticket_item_id, SUM(rr.rebate_amount) as total_rebate') + ->groupBy('rr.ticket_item_id') + ->pluck('total_rebate', 'ticket_item_id') + ->map(static fn ($amount): int => (int) $amount) + ->all(); + } + + /** + * @param list $ticketIds + * @return array + */ + private function ticketAvailableDeltas(int $playerId, array $ticketIds): array + { + if ($ticketIds === []) { + return []; + } + + return DB::table('credit_ledger') + ->where('owner_type', 'player') + ->where('owner_id', $playerId) + ->where('ref_type', 'ticket_item') + ->whereIn('ref_id', $ticketIds) + ->whereIn('reason', ['game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal']) + ->selectRaw('ref_id, SUM(amount) as available_delta') + ->groupBy('ref_id') + ->pluck('available_delta', 'ref_id') + ->map(static fn ($amount): int => (int) $amount) + ->all(); + } + + /** + * @return array + */ + private function formatActivity( + string $logId, + string $type, + string $bizType, + string $activityKind, + string $activityStatus, + int $amount, + string $currency, + int $availableDelta, + ?string $createdAt, + object $order, + int $ticketCount, + ?string $ticketNo, + int $stakeAmount, + int $winAmount, + int $rebateAmount, + ): array { + return [ + 'log_id' => $logId, + 'type' => $type, + 'biz_type' => $bizType, + 'activity_kind' => $activityKind, + 'activity_status' => $activityStatus, + 'amount' => $amount, + 'amount_abs' => abs($amount), + 'direction' => $amount >= 0 ? 'in' : 'out', + 'currency_code' => $currency, + 'balance_after' => null, + 'affects_available_credit' => $availableDelta !== 0, + '_available_delta' => $availableDelta, + 'ref_id' => (string) $order->order_no, + 'order_no' => (string) $order->order_no, + 'draw_no' => $order->draw_no !== null ? (string) $order->draw_no : null, + 'ticket_no' => $ticketNo !== '' ? $ticketNo : null, + 'ticket_count' => $ticketCount, + 'stake_amount' => $stakeAmount, + 'win_amount' => $winAmount, + 'rebate_amount' => $rebateAmount, + 'net_amount' => $amount, + 'settlement_bill_id' => null, + 'idempotent_key' => null, + 'external_ref_no' => null, + 'status' => $activityStatus === 'pending' ? 'pending_reconcile' : ($activityStatus === 'reversed' ? 'reversed' : 'posted'), + 'remark' => null, + 'created_at' => $createdAt, + 'ledger_source' => 'credit_activity', + ]; + } + + private function latestTimestamp(mixed $current, mixed $candidate): mixed + { + if ($candidate === null || $candidate === '') { + return $current; + } + if ($current === null || $current === '') { + return $candidate; + } + + return strtotime((string) $candidate) > strtotime((string) $current) ? $candidate : $current; + } + + private function isoTimestamp(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return Carbon::parse((string) $value)->toIso8601String(); + } +} diff --git a/app/Services/Wallet/PlayerLedgerLogsService.php b/app/Services/Wallet/PlayerLedgerLogsService.php index de10fbf..0bb5e82 100644 --- a/app/Services/Wallet/PlayerLedgerLogsService.php +++ b/app/Services/Wallet/PlayerLedgerLogsService.php @@ -3,19 +3,20 @@ namespace App\Services\Wallet; use Carbon\Carbon; -use App\Models\AdminUser; use App\Models\Player; +use App\Models\AdminUser; use App\Models\WalletTxn; use Illuminate\Support\Str; -use App\Support\AdminDataScope; -use App\Support\CurrencyFormatter; use App\Support\LimitedQuery; +use App\Support\AdminDataScope; +use App\Support\CreditAmountScale; +use App\Support\CurrencyFormatter; use App\Support\PlayerFundingMode; -use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter; -use App\Services\AgentSettlement\SettlementPartyEnrichment; +use Illuminate\Support\Facades\DB; use App\Services\Player\PlayerCreditService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; -use Illuminate\Support\Facades\DB; +use App\Services\AgentSettlement\SettlementPartyEnrichment; +use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter; /** * 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}。 @@ -48,6 +49,7 @@ final class PlayerLedgerLogsService public function __construct( private readonly PlayerCreditService $playerCreditService, + private readonly PlayerCreditActivityService $creditActivityService, private readonly CreditLedgerBetFlowPresenter $betFlowPresenter, private readonly SettlementPartyEnrichment $partyEnrichment, ) {} @@ -430,6 +432,127 @@ final class PlayerLedgerLogsService int $page, int $perPage, string $typeFilterRaw, + ): array { + $activities = $this->creditActivityService->build($player); + if ($activities !== []) { + return $this->paginateCreditActivities($player, $activities, $page, $perPage, $typeFilterRaw); + } + + return $this->paginateLegacyCreditLedger($player, $page, $perPage, $typeFilterRaw); + } + + /** + * @param list> $activities + * @return array{items: list>, total: int, page: int, per_page: int} + */ + private function paginateCreditActivities( + Player $player, + array $activities, + int $page, + int $perPage, + string $typeFilterRaw, + ): array { + $currency = (string) $player->default_currency; + $creditLimitMinor = $this->creditLimitMinor($player, $currency); + $runningMinor = $this->clampCreditAvailableMinor( + $this->playerCreditService->availableCreditMinor($player, $currency), + $creditLimitMinor, + ); + + // 先按完整活动时间线回推每笔的变更后额度,再做筛选和分页。 + // 否则筛选掉的较新活动会让历史记录的 balance_after 偏离真实值。 + foreach ($activities as &$activity) { + $activity['balance_after'] = $runningMinor; + $activity['balance_after_formatted'] = CurrencyFormatter::fromMinor($runningMinor); + $activity['funding_mode'] = (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT); + $activity['auth_source'] = $player->auth_source; + $runningMinor = $this->clampCreditAvailableMinor( + $runningMinor - (int) ($activity['_available_delta'] ?? 0), + $creditLimitMinor, + ); + unset($activity['_available_delta']); + } + unset($activity); + + $activities = array_values(array_filter( + $activities, + fn (array $activity): bool => $this->creditActivityMatchesFilter($activity, $typeFilterRaw), + )); + $offset = max(0, ($page - 1) * $perPage); + + $items = array_slice($activities, $offset, $perPage); + + return [ + 'items' => $items, + 'total' => count($activities), + 'page' => $page, + 'per_page' => $perPage, + ]; + } + + /** + * @param array $activity + */ + private function creditActivityMatchesFilter(array $activity, string $typeFilterRaw): bool + { + $raw = trim($typeFilterRaw); + if ($raw === '') { + return true; + } + + $parts = array_values(array_filter(array_map( + static fn (string $part): string => Str::lower(trim($part)), + explode(',', $raw), + ))); + if ($parts === []) { + return true; + } + + $kind = (string) ($activity['activity_kind'] ?? ''); + $status = (string) ($activity['activity_status'] ?? ''); + $netAmount = (int) ($activity['net_amount'] ?? 0); + $rebateAmount = (int) ($activity['rebate_amount'] ?? 0); + + foreach ($parts as $part) { + if ($part === 'bet' && $kind === 'bet') { + return true; + } + if ($part === 'game_settlement' && $kind === 'draw_result') { + return true; + } + if ($part === 'bill_settlement' && $kind === 'period_settlement') { + return true; + } + if ($part === 'rebate' && $kind === 'draw_result' && $rebateAmount > 0) { + return true; + } + if ($part === 'reversal' && ($kind === 'refund' || $status === 'reversed')) { + return true; + } + if ($part === 'refund' && $kind === 'refund') { + return true; + } + if ($part === 'win_credit' && $kind === 'draw_result' && $netAmount > 0) { + return true; + } + if ($part === 'credit_release' && in_array($kind, ['draw_result', 'refund', 'period_settlement'], true)) { + return true; + } + } + + return false; + } + + /** + * 兼容没有订单领域记录的旧数据与孤立测试数据;真实玩家活动优先走聚合视图。 + * + * @return array{items: list>, total: int, page: int, per_page: int} + */ + private function paginateLegacyCreditLedger( + Player $player, + int $page, + int $perPage, + string $typeFilterRaw, ): array { $reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw); $includeRebates = $this->creditFilterIncludesRebates($typeFilterRaw); @@ -732,7 +855,7 @@ final class PlayerLedgerLogsService return 0; } - return \App\Support\CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency); + return CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency); } private function clampCreditAvailableMinor(int $amountMinor, int $creditLimitMinor): int diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index bb44fb8..7434193 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -407,6 +407,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], ['code' => 'admin.dashboard.analytics', 'module_code' => 'dashboard', 'name' => '仪表盘分析', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard/analytics', 'route_name' => 'api.v1.admin.dashboard.analytics', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], ['code' => 'admin.auth.me', 'module_code' => 'system', 'name' => '后台当前管理员摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/auth/me', 'route_name' => 'api.v1.admin.auth.me', 'auth_mode' => 'login_only', 'is_audit_required' => false], + ['code' => 'admin.auth.logout', 'module_code' => 'system', 'name' => '后台退出登录', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/auth/logout', 'route_name' => 'api.v1.admin.auth.logout', 'auth_mode' => 'login_only', 'is_audit_required' => false], ['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']], ['code' => 'admin.admin-users.index', 'module_code' => 'system', 'name' => '管理员列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']], @@ -527,7 +528,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']], ['code' => 'admin.draws.cancel', 'module_code' => 'draw', 'name' => '取消开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/cancel', 'route_name' => 'api.v1.admin.draws.cancel', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.rng', 'module_code' => 'draw', 'name' => '执行开奖 RNG', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/rng', 'route_name' => 'api.v1.admin.draws.rng', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], - ['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.review']], + ['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.batch.manage']], ['code' => 'admin.settlement-batches.index', 'module_code' => 'settlement', 'name' => '结算批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches', 'route_name' => 'api.v1.admin.settlement-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], ['code' => 'admin.settlement-batches.show', 'module_code' => 'settlement', 'name' => '结算批次详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}', 'route_name' => 'api.v1.admin.settlement-batches.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], diff --git a/app/Support/AuditLogApiPresenter.php b/app/Support/AuditLogApiPresenter.php index d2ce0e1..9f688f0 100644 --- a/app/Support/AuditLogApiPresenter.php +++ b/app/Support/AuditLogApiPresenter.php @@ -2,9 +2,9 @@ namespace App\Support; +use App\Models\Player; use App\Models\AuditLog; use App\Models\AdminUser; -use App\Models\Player; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -59,6 +59,8 @@ final class AuditLogApiPresenter 'sync_permissions' => '同步权限', 'batch_update' => '批量更新设置', 'payout_adjustment' => '派彩调整', + 'auto_payout' => '自动派彩成功', + 'auto_payout_failed' => '自动派彩失败', 'rotate_secrets' => '轮换密钥', 'toggle_active' => '切换启用状态', 'enqueue' => '提交报表导出', @@ -88,6 +90,7 @@ final class AuditLogApiPresenter 'play_config_item' => '玩法', 'play_config_version' => '玩法配置版本', 'settlement_batch_adjustment' => '结算调整单', + 'settlement_batch' => '结算批次', 'report_job' => '报表任务', 'reconcile_job' => '对账任务', 'transfer_no' => '转账单', diff --git a/bootstrap/app.php b/bootstrap/app.php index db277c5..25ec511 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -267,10 +267,14 @@ return Application::configure(basePath: dirname(__DIR__)) $withSingleServerLock($schedule->command('settlement:mark-overdue-bills --days=7') ->dailyAt('02:00') ->withoutOverlapping()); - /** @see docs/01-界面文档.md §2.1 `draw.countdown` */ + /** + * @see docs/01-界面文档.md §2.1 `draw.countdown` + * 前端按服务器时间每秒本地计时;后端每 5 秒校准快照和期号边界, + * 避免每秒重复启动完整 Artisan 进程。 + */ if (config('lottery.realtime_hall_countdown', true)) { $withSingleServerLock($schedule->command('lottery:hall-countdown') - ->everySecond() + ->everyFiveSeconds() ->withoutOverlapping(expiresAt: 5)); } }) diff --git a/config/lottery.php b/config/lottery.php index fed028f..d1ce2c3 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -133,5 +133,7 @@ return [ 'draw_tick_stage_warn_threshold_ms' => max(50, (int) env('LOTTERY_DRAW_TICK_STAGE_WARN_THRESHOLD_MS', 500)), 'draw_tick_settle_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_SETTLE_LIMIT', 3)), 'draw_tick_finalize_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_FINALIZE_LIMIT', 5)), + 'auto_payout_retry_base_seconds' => max(10, (int) env('LOTTERY_AUTO_PAYOUT_RETRY_BASE_SECONDS', 60)), + 'auto_payout_retry_max_seconds' => max(60, (int) env('LOTTERY_AUTO_PAYOUT_RETRY_MAX_SECONDS', 3600)), 'draw_tick_rng_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_RNG_LIMIT', 10)), ]; diff --git a/database/migrations/2026_07_22_120000_add_native_session_version_to_players.php b/database/migrations/2026_07_22_120000_add_native_session_version_to_players.php new file mode 100644 index 0000000..81354dd --- /dev/null +++ b/database/migrations/2026_07_22_120000_add_native_session_version_to_players.php @@ -0,0 +1,24 @@ +unsignedInteger('native_session_version') + ->default(0) + ->after('native_token_version'); + }); + } + + public function down(): void + { + Schema::table('players', function (Blueprint $table): void { + $table->dropColumn('native_session_version'); + }); + } +}; diff --git a/database/migrations/2026_07_22_130000_add_admin_session_version_to_admin_users.php b/database/migrations/2026_07_22_130000_add_admin_session_version_to_admin_users.php new file mode 100644 index 0000000..099cc99 --- /dev/null +++ b/database/migrations/2026_07_22_130000_add_admin_session_version_to_admin_users.php @@ -0,0 +1,24 @@ +unsignedInteger('admin_session_version') + ->default(0) + ->after('last_login_at'); + }); + } + + public function down(): void + { + Schema::table('admin_users', function (Blueprint $table): void { + $table->dropColumn('admin_session_version'); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 9e79c33..ec63fdc 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -5,6 +5,7 @@ return [ 'invalid_captcha' => 'Invalid or expired captcha.', 'invalid_credentials' => 'Invalid account or password.', 'account_disabled' => 'This account has been disabled.', + 'session_replaced' => 'This account was signed in elsewhere. The current session has been signed out.', 'permission_denied' => 'You do not have permission to perform this action.', 'forbidden' => 'You do not have permission to perform this action.', 'settlement_run_skipped' => 'Settlement was not run for this draw (check draw status and published result batch).', diff --git a/lang/en/api.php b/lang/en/api.php index d2ff5d5..923a914 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -41,6 +41,7 @@ return [ 'jackpot_already_allocated_for_draw' => 'Jackpot was already allocated for this draw.', 'draw_not_ready_for_jackpot_burst' => 'Draw is not in settling or settled status.', 'draw_result_not_published' => 'Draw results have not been published.', + 'draw_not_ready_for_settlement' => 'This draw cannot be settled in its current state. Settlement may only be started early during cooldown or retried while settling.', 'settlement_batch_not_found' => 'Settlement batch not found for this draw.', 'settlement_not_pending_review' => 'Settlement batch is not pending review.', 'settlement_not_approved' => 'Settlement batch is not approved.', diff --git a/lang/en/sso.php b/lang/en/sso.php index c67a71e..8a28a1e 100644 --- a/lang/en/sso.php +++ b/lang/en/sso.php @@ -16,4 +16,5 @@ return [ '8007' => 'Too many failed attempts. Try again later', '8008' => 'Please sign in through the main site', '8009' => 'Invalid or expired captcha.', + '8010' => 'This account was signed in on another device. Please sign in again.', ]; diff --git a/lang/ne/admin.php b/lang/ne/admin.php index 6e5a8ed..e2d34f2 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -5,6 +5,7 @@ return [ 'invalid_captcha' => 'क्याप्चा गलत वा म्याद सकिएको छ।', 'invalid_credentials' => 'खाता वा पासवर्ड गलत छ।', 'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।', + 'session_replaced' => 'यो खाता अर्को स्थानमा लगइन भएको छ। हालको सत्र लगआउट गरिएको छ।', 'permission_denied' => 'यो कार्य गर्ने अनुमति छैन।', 'forbidden' => 'यो कार्य गर्ने अनुमति छैन।', 'settlement_run_skipped' => 'यो ड्रको सेटलमेन्ट चलाइएन (ड्र स्थिति र प्रकाशित नतिजा जाँच गर्नुहोस्)।', diff --git a/lang/ne/api.php b/lang/ne/api.php index ef064c2..46c3575 100644 --- a/lang/ne/api.php +++ b/lang/ne/api.php @@ -41,6 +41,7 @@ return [ 'jackpot_already_allocated_for_draw' => 'यो ड्रमा ज्याकपोट पहिले नै बाँडिएको छ।', 'draw_not_ready_for_jackpot_burst' => 'ड्र settling वा settled अवस्थामा छैन।', 'draw_result_not_published' => 'ड्र नतिजा प्रकाशित भएको छैन।', + 'draw_not_ready_for_settlement' => 'हालको अवस्थामा यो ड्र सेटल गर्न मिल्दैन। कूलडाउनमा मात्र छिटो सेटल गर्न वा सेटल हुँदै गर्दा पुनः प्रयास गर्न सकिन्छ।', 'settlement_batch_not_found' => 'यो ड्रको सेटलमेन्ट ब्याच फेला परेन।', 'settlement_not_pending_review' => 'सेटलमेन्ट ब्याच समीक्षामा छैन।', 'settlement_not_approved' => 'सेटलमेन्ट ब्याच स्वीकृत छैन।', diff --git a/lang/ne/sso.php b/lang/ne/sso.php index 821cbe3..409338b 100644 --- a/lang/ne/sso.php +++ b/lang/ne/sso.php @@ -13,4 +13,5 @@ return [ '8007' => 'धेरै असफल प्रयास। पछि फेरि प्रयास गर्नुहोस्', '8008' => 'कृपया मुख्य साइटबाट लगइन गर्नुहोस्', '8009' => 'क्याप्चा गलत वा म्याद सकिएको छ।', + '8010' => 'यो खाता अर्को उपकरणमा लगइन गरिएको छ। कृपया फेरि लगइन गर्नुहोस्।', ]; diff --git a/lang/zh/admin.php b/lang/zh/admin.php index c6cdaf6..782319d 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -5,6 +5,7 @@ return [ 'invalid_captcha' => '验证码错误或已过期,请重试。', 'invalid_credentials' => '账号或密码错误。', 'account_disabled' => '该账号已被禁用。', + 'session_replaced' => '该账号已在其他地方登录,当前会话已退出。', 'permission_denied' => '当前账号无此操作权限。', 'forbidden' => '当前账号无此操作权限。', 'settlement_run_skipped' => '本期未执行结算(请检查期号状态与已发布开奖批次)。', diff --git a/lang/zh/api.php b/lang/zh/api.php index c52c6f7..d03c0a7 100644 --- a/lang/zh/api.php +++ b/lang/zh/api.php @@ -41,6 +41,7 @@ return [ 'jackpot_already_allocated_for_draw' => '该期已分配过奖池派彩。', 'draw_not_ready_for_jackpot_burst' => '期号尚未进入结算中或已结算,无法手动爆池。', 'draw_result_not_published' => '该期开奖结果尚未发布。', + 'draw_not_ready_for_settlement' => '当前期号状态不允许结算;仅可在冷静期提前结算,或在结算中重新触发。', 'settlement_batch_not_found' => '未找到该期的结算批次。', 'settlement_not_pending_review' => '结算批次不在待审核状态。', 'settlement_not_approved' => '结算批次尚未审核通过。', diff --git a/lang/zh/sso.php b/lang/zh/sso.php index 966bf8f..a932557 100644 --- a/lang/zh/sso.php +++ b/lang/zh/sso.php @@ -13,4 +13,5 @@ return [ '8007' => '登录失败次数过多,请稍后再试', '8008' => '请使用主站登录进入彩票', '8009' => '验证码错误或已过期,请重试。', + '8010' => '该账号已在其他设备登录,请重新登录。', ]; diff --git a/routes/api/v1/admin/core.php b/routes/api/v1/admin/core.php index ff14383..4358060 100644 --- a/routes/api/v1/admin/core.php +++ b/routes/api/v1/admin/core.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\Admin\Auth\MeController; +use App\Http\Controllers\Api\V1\Admin\Auth\LogoutController; use App\Http\Controllers\Api\V1\Admin\Audit\AuditLogIndexController; use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardAnalyticsController; use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardController; @@ -30,6 +31,10 @@ Route::get('auth/me', MeController::class) ->middleware('admin.api-resource') ->name('api.v1.admin.auth.me'); +Route::post('auth/logout', LogoutController::class) + ->middleware('admin.api-resource') + ->name('api.v1.admin.auth.logout'); + // 审计日志 Route::middleware('admin.api-resource') ->get('audit-logs', AuditLogIndexController::class) diff --git a/routes/api/v1/player.php b/routes/api/v1/player.php index 7900801..cb46a8a 100644 --- a/routes/api/v1/player.php +++ b/routes/api/v1/player.php @@ -51,7 +51,7 @@ Route::middleware('lottery.player')->group(function (): void { ->where('ticket_no', 'TK[0-9]+') ->name('items.show'); Route::get('draws/{draw_no}/my-match', TicketDrawMyMatchController::class) - ->where('draw_no', '[0-9]{8}-[0-9]{3}') + ->where('draw_no', '[0-9]{8}-[0-9]{3,}') ->name('draws.my-match'); }); diff --git a/routes/api/v1/public.php b/routes/api/v1/public.php index 5aeb6e9..b8e20d8 100644 --- a/routes/api/v1/public.php +++ b/routes/api/v1/public.php @@ -4,14 +4,14 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\HealthController; use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; use App\Http\Controllers\Api\V1\Draw\DrawResultShowController; +use App\Http\Controllers\Api\V1\Setting\SettingIndexController; use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController; use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController; use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController; +use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController; +use App\Http\Controllers\Api\V1\Player\PlayerAuthCaptchaController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; -use App\Http\Controllers\Api\V1\Player\PlayerAuthCaptchaController; -use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController; -use App\Http\Controllers\Api\V1\Setting\SettingIndexController; use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController; /** @@ -28,7 +28,7 @@ Route::get('currencies', CurrencyIndexController::class)->name('api.v1.currencie Route::get('draw/current', DrawCurrentController::class)->name('api.v1.draw.current'); Route::get('draw/results', DrawResultsIndexController::class)->name('api.v1.draw.results'); Route::get('draw/results/{draw_no}', DrawResultShowController::class) - ->where('draw_no', '[0-9]{8}-[0-9]{3}') + ->where('draw_no', '[0-9]{8}-[0-9]{3,}') ->name('api.v1.draw.results.show'); // 奖池水位(公开) diff --git a/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php b/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php index d0ab1ac..172944a 100644 --- a/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php +++ b/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php @@ -1,8 +1,10 @@ assertJsonPath('code', ErrorCode::AdminForbidden->value); }); +test('admin api resource middleware denies settlement review only account from running draw settlement', function (): void { + $token = mintAdminTokenWithLegacySlugs('resource_settlement_reviewer', ['prd.payout.review']); + $draw = Draw::query()->create([ + 'draw_no' => '20260722-2001', + 'business_date' => '2026-07-22', + 'sequence_no' => 2001, + 'status' => DrawStatus::Cooldown->value, + 'cooling_end_time' => now()->addMinutes(10), + 'settle_version' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run") + ->assertForbidden() + ->assertJsonPath('code', ErrorCode::AdminForbidden->value); + + expect($draw->fresh()->status)->toBe(DrawStatus::Cooldown->value); +}); + test('admin api resource middleware allows wallet reconcile resource with mapped permission', function (): void { $token = mintAdminTokenWithLegacySlugs('resource_wallet_viewer', ['prd.wallet_reconcile.view']); diff --git a/tests/Feature/AdminAuditLogDedupTest.php b/tests/Feature/AdminAuditLogDedupTest.php index 0bbbd24..4bcb3b1 100644 --- a/tests/Feature/AdminAuditLogDedupTest.php +++ b/tests/Feature/AdminAuditLogDedupTest.php @@ -2,11 +2,12 @@ use App\Models\AuditLog; use App\Models\AdminUser; -use App\Support\AuditLogApiPresenter; use App\Lottery\ErrorCode; +use App\Services\AuditLogger; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; -use App\Services\AuditLogger; +use App\Support\AuditLogApiPresenter; +use App\Services\Agent\AgentNodeService; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -36,6 +37,24 @@ test('audit log presenter maps business action and entity target', function (): ->and($payload['summary_label'])->toBe('代理管理 · 同步代理角色权限(角色 #5)'); }); +test('audit log presenter translates automatic payout failures', function (): void { + $row = new AuditLog([ + 'operator_type' => 'system', + 'operator_id' => 0, + 'module_code' => 'settlement', + 'action_code' => 'auto_payout_failed', + 'target_type' => 'settlement_batch', + 'target_id' => '1107', + ]); + $row->id = 2; + + $payload = AuditLogApiPresenter::row($row); + + expect($payload['action_label'])->toBe('自动派彩失败') + ->and($payload['target_label'])->toBe('结算批次 #1107') + ->and($payload['summary_label'])->toBe('派彩结算 · 自动派彩失败(结算批次 #1107)'); +}); + test('audit log presenter uses admin api resource name for middleware style rows', function (): void { $resourceName = (string) DB::table('admin_api_resources') ->where('code', 'admin.agent-roles.permissions.sync') @@ -63,7 +82,7 @@ test('audit log presenter uses admin api resource name for middleware style rows test('agent role permission sync records one business audit and skips middleware duplicate', function (): void { $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); - $service = app(\App\Services\Agent\AgentNodeService::class); + $service = app(AgentNodeService::class); $super = AdminUser::query()->create([ 'username' => 'audit_dedup_super', diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 5e478c9..23c340c 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -2,8 +2,8 @@ use App\Models\AdminUser; use App\Lottery\ErrorCode; -use App\Support\SitePlatformRole; use Illuminate\Support\Str; +use App\Support\SitePlatformRole; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Cache; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -92,6 +92,89 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('data.scope', 'admin'); }); +test('later admin login replaces the previous browser session', function () { + $admin = AdminUser::query()->create([ + 'username' => 'single_session_admin', + 'name' => '单会话管理员', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + $login = function () { + $captchaKey = (string) Str::uuid(); + Cache::put( + 'admin_captcha:'.$captchaKey, + hash_hmac('sha256', 'xwz2', (string) config('app.key')), + now()->addSeconds(120), + ); + + return $this->postJson('/api/v1/admin/auth/login', [ + 'account' => 'single_session_admin', + 'password' => 'secret-strong', + 'captcha_key' => $captchaKey, + 'captcha_code' => 'xwz2', + ])->assertOk(); + }; + + $firstToken = (string) $login()->json('data.token'); + + $this->withHeader('Authorization', 'Bearer '.$firstToken) + ->getJson('/api/v1/admin/ping') + ->assertOk(); + + $secondToken = (string) $login()->json('data.token'); + + app('auth')->forgetGuards(); + $this->withHeader('Authorization', 'Bearer '.$firstToken) + ->getJson('/api/v1/admin/ping') + ->assertUnauthorized() + ->assertJsonPath('code', ErrorCode::AdminSessionReplaced->value); + + app('auth')->forgetGuards(); + $this->withHeader('Authorization', 'Bearer '.$secondToken) + ->getJson('/api/v1/admin/ping') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value); + + expect($admin->fresh()->admin_session_version)->toBe(2) + ->and($admin->tokens()->where('name', 'admin-api')->count())->toBe(2); +}); + +test('admin logout revokes browser sessions without deleting programmatic tokens', function () { + $admin = AdminUser::query()->create([ + 'username' => 'logout_admin', + 'name' => '退出管理员', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + 'admin_session_version' => 1, + ]); + + $browserToken = $admin->createToken( + 'admin-api', + ['admin-session:1'], + now()->addDay(), + )->plainTextToken; + $admin->createToken('test', ['*'], now()->addDay()); + + $this->withHeader('Authorization', 'Bearer '.$browserToken) + ->postJson('/api/v1/admin/auth/logout') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.logged_out', true); + + expect($admin->tokens()->where('name', 'admin-api')->count())->toBe(0) + ->and($admin->tokens()->where('name', 'test')->count())->toBe(1); + + app('auth')->forgetGuards(); + $this->withHeader('Authorization', 'Bearer '.$browserToken) + ->getJson('/api/v1/admin/ping') + ->assertUnauthorized() + ->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value); +}); + test('agent operator auth me omits platform-only navigation', function (): void { $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); diff --git a/tests/Feature/CreditWalletLogsTest.php b/tests/Feature/CreditWalletLogsTest.php index 7065f77..508bffe 100644 --- a/tests/Feature/CreditWalletLogsTest.php +++ b/tests/Feature/CreditWalletLogsTest.php @@ -3,10 +3,11 @@ use App\Models\Player; use App\Support\PlayerAuthSource; use App\Support\PlayerFundingMode; +use Illuminate\Support\Facades\DB; use Database\Seeders\CurrencySeeder; use Database\Seeders\LotterySettingsSeeder; +use App\Services\Player\PlayerCreditService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\DB; uses(RefreshDatabase::class); @@ -345,3 +346,505 @@ test('credit wallet logs page 2 balance_after continues from page 1 baseline', f // Page 2 must continue the running balance from page 1, capped by credit limit. expect($page2FirstBalanceAfter)->toBe(min($page1LastBalanceAfter + 1000, 50000)); }); + +test('credit player activity merges hold release loss and rebate into one order result', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:activity-loss', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_activity_loss', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 21, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $drawId = (int) DB::table('draws')->insertGetId([ + 'draw_no' => 'ACTIVITY-LOSS-001', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => 'settled', + 'created_at' => now()->subMinutes(10), + 'updated_at' => now(), + ]); + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORDER-ACTIVITY-LOSS-001', + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => 2100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 2100, + 'total_estimated_payout' => 50000, + 'status' => 'placed', + 'created_at' => now()->subMinutes(10), + 'updated_at' => now(), + ]); + $ticketItemId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'TICKET-ACTIVITY-LOSS-001', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => '4d', + 'total_bet_amount' => 2100, + 'actual_deduct_amount' => 2100, + 'status' => 'settled_lose', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => now(), + 'created_at' => now()->subMinutes(10), + 'updated_at' => now(), + ]); + + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $ticketItemId, + 'player_id' => $player->id, + 'agent_node_id' => null, + 'game_win_loss' => 2100, + 'basic_rebate' => 11, + 'shared_net_win_loss' => 2089, + 'settled_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('rebate_records')->insert([ + 'player_id' => $player->id, + 'ticket_item_id' => $ticketItemId, + 'game_type' => '4d', + 'valid_bet_amount' => 2100, + 'rebate_rate' => 0.0052, + 'rebate_amount' => 11, + 'rebate_type' => 'basic', + 'owner_agent_id' => null, + 'status' => 'accrued', + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('credit_ledger')->insert([ + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -2100, + 'reason' => 'bet_hold', + 'ref_type' => 'ticket_order', + 'ref_id' => $orderId, + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 2100, + 'reason' => 'bet_hold_release', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -2100, + 'reason' => 'game_settlement_loss', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=1&size=10') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.activity_kind', 'draw_result') + ->assertJsonPath('data.items.0.biz_type', 'settled_loss') + ->assertJsonPath('data.items.0.order_no', 'ORDER-ACTIVITY-LOSS-001') + ->assertJsonPath('data.items.0.draw_no', 'ACTIVITY-LOSS-001') + ->assertJsonPath('data.items.0.ticket_no', 'TICKET-ACTIVITY-LOSS-001') + ->assertJsonPath('data.items.0.stake_amount', 2100) + ->assertJsonPath('data.items.0.rebate_amount', 11) + ->assertJsonPath('data.items.0.net_amount', -2089) + ->assertJsonPath('data.items.0.balance_after', 47900); + + expect($response->json('data.items'))->toHaveCount(1); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?type=bet') + ->assertOk() + ->assertJsonPath('data.total', 0); + + $newerOrderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORDER-ACTIVITY-PENDING-001', + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 1000, + 'status' => 'placed', + 'created_at' => now()->addSecond(), + 'updated_at' => now()->addSecond(), + ]); + DB::table('ticket_items')->insert([ + 'ticket_no' => 'TICKET-ACTIVITY-PENDING-001', + 'order_id' => $newerOrderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'original_number' => '5678', + 'normalized_number' => '5678', + 'play_code' => '4d', + 'total_bet_amount' => 100, + 'actual_deduct_amount' => 100, + 'status' => 'pending_draw', + 'created_at' => now()->addSecond(), + 'updated_at' => now()->addSecond(), + ]); + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update(['used_credit' => 22, 'updated_at' => now()]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?type=game_settlement') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.balance_after', 47900); +}); + +test('credit player activities describe wins refunds and settlement reversals in player terms', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:activity-outcomes', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_activity_outcomes', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 1000, + 'used_credit' => 7, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $drawId = (int) DB::table('draws')->insertGetId([ + 'draw_no' => 'ACTIVITY-OUTCOMES-001', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $createOrder = function ( + string $orderNo, + string $ticketNo, + string $orderStatus, + string $ticketStatus, + int $stake, + int $minutesAgo, + ) use ($player, $drawId): array { + $createdAt = now()->subMinutes($minutesAgo); + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => $orderNo, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => $stake, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => $stake, + 'total_estimated_payout' => 5000, + 'status' => $orderStatus, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + $ticketId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => $ticketNo, + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => '4d', + 'total_bet_amount' => $stake, + 'actual_deduct_amount' => $stake, + 'status' => $ticketStatus, + 'win_amount' => $ticketStatus === 'settled_win' ? 4000 : 0, + 'jackpot_win_amount' => 0, + 'settled_at' => $ticketStatus === 'settled_win' ? $createdAt : null, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + return [$orderId, $ticketId]; + }; + + [, $winningTicketId] = $createOrder( + 'ORDER-ACTIVITY-WIN-001', + 'TICKET-ACTIVITY-WIN-001', + 'placed', + 'settled_win', + 1000, + 3, + ); + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $winningTicketId, + 'player_id' => $player->id, + 'agent_node_id' => null, + 'game_win_loss' => -4000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => -4000, + 'settled_at' => now()->subMinutes(3), + 'created_at' => now()->subMinutes(3), + 'updated_at' => now()->subMinutes(3), + ]); + + $createOrder( + 'ORDER-ACTIVITY-REFUND-001', + 'TICKET-ACTIVITY-REFUND-001', + 'refunded', + 'refunded', + 500, + 2, + ); + + [, $reversedTicketId] = $createOrder( + 'ORDER-ACTIVITY-REVERSED-001', + 'TICKET-ACTIVITY-REVERSED-001', + 'placed', + 'pending_draw', + 700, + 1, + ); + $originalShareId = (int) DB::table('share_ledger')->insertGetId([ + 'ticket_item_id' => $reversedTicketId, + 'player_id' => $player->id, + 'agent_node_id' => null, + 'game_win_loss' => 700, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 700, + 'settled_at' => now()->subMinute(), + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $reversedTicketId, + 'player_id' => $player->id, + 'agent_node_id' => null, + 'game_win_loss' => -700, + 'basic_rebate' => 0, + 'shared_net_win_loss' => -700, + 'reversal_of_id' => $originalShareId, + 'settled_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $items = collect($this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=1&size=10') + ->assertOk() + ->assertJsonPath('data.total', 3) + ->json('data.items')) + ->keyBy('order_no'); + + expect($items['ORDER-ACTIVITY-WIN-001']['activity_kind'])->toBe('draw_result') + ->and($items['ORDER-ACTIVITY-WIN-001']['biz_type'])->toBe('settled_win') + ->and($items['ORDER-ACTIVITY-WIN-001']['win_amount'])->toBe(4000) + ->and($items['ORDER-ACTIVITY-WIN-001']['net_amount'])->toBe(4000) + ->and($items['ORDER-ACTIVITY-REFUND-001']['activity_kind'])->toBe('refund') + ->and($items['ORDER-ACTIVITY-REFUND-001']['biz_type'])->toBe('bet_refund') + ->and($items['ORDER-ACTIVITY-REFUND-001']['net_amount'])->toBe(500) + ->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_kind'])->toBe('bet') + ->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_status'])->toBe('pending') + ->and($items['ORDER-ACTIVITY-REVERSED-001']['net_amount'])->toBe(-700); +}); + +test('credit hold records the real ticket order reference when provided', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:hold-order-ref', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_hold_order_ref', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(PlayerCreditService::class)->holdForBet($player, 2100, 77); + + $row = DB::table('credit_ledger')->where('owner_id', $player->id)->first(); + expect($row?->ref_type)->toBe('ticket_order') + ->and((int) $row?->ref_id)->toBe(77); +}); + +test('credit player activity shows each partial period payment as one player-facing record', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:period-payments', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_period_payments', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $siteId = (int) DB::table('admin_sites')->where('code', 'default_site')->value('id'); + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => 0, + 'net_amount' => 300, + 'paid_amount' => 300, + 'unpaid_amount' => 0, + 'status' => 'settled', + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('payment_records')->insert([ + [ + 'settlement_bill_id' => $billId, + 'payer_type' => 'player', + 'payer_id' => $player->id, + 'payee_type' => 'agent', + 'payee_id' => 0, + 'amount' => 100, + 'status' => 'confirmed', + 'confirmed_at' => now()->subMinute(), + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ], + [ + 'settlement_bill_id' => $billId, + 'payer_type' => 'player', + 'payer_id' => $player->id, + 'payee_type' => 'agent', + 'payee_id' => 0, + 'amount' => 200, + 'status' => 'confirmed', + 'confirmed_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?type=bill_settlement') + ->assertOk() + ->assertJsonPath('data.total', 2) + ->assertJsonPath('data.items.0.activity_kind', 'period_settlement') + ->assertJsonPath('data.items.0.biz_type', 'period_paid') + ->assertJsonPath('data.items.0.net_amount', -200) + ->assertJsonPath('data.items.1.net_amount', -100); +}); + +test('credit player activity paginates after grouping orders', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:activity-pages', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_activity_pages', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 11, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $drawId = (int) DB::table('draws')->insertGetId([ + 'draw_no' => 'ACTIVITY-PAGE-001', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + for ($i = 1; $i <= 11; $i++) { + $placedAt = now()->subMinutes(11 - $i); + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORDER-ACTIVITY-PAGE-'.$i, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 1000, + 'status' => 'placed', + 'created_at' => $placedAt, + 'updated_at' => $placedAt, + ]); + DB::table('ticket_items')->insert([ + 'ticket_no' => 'TICKET-ACTIVITY-PAGE-'.$i, + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => '4d', + 'total_bet_amount' => 100, + 'actual_deduct_amount' => 100, + 'status' => 'pending_draw', + 'created_at' => $placedAt, + 'updated_at' => $placedAt, + ]); + } + + $page1 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=1&size=10') + ->assertOk() + ->json('data'); + $page2 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=2&size=10') + ->assertOk() + ->json('data'); + + expect($page1['total'])->toBe(11) + ->and($page1['items'])->toHaveCount(10) + ->and($page2['items'])->toHaveCount(1) + ->and($page2['items'][0]['activity_kind'])->toBe('bet'); +}); diff --git a/tests/Feature/DrawResultsApiTest.php b/tests/Feature/DrawResultsApiTest.php index e6bd8b8..0854020 100644 --- a/tests/Feature/DrawResultsApiTest.php +++ b/tests/Feature/DrawResultsApiTest.php @@ -1,8 +1,8 @@ assertJsonPath('data.next_draw_no', '20260509-102'); }); +test('draw result show accepts sequence numbers longer than three digits', function (): void { + seedMinimalPublishedDraw([ + 'draw_no' => '20260509-1001', + 'business_date' => '2026-05-09', + 'sequence_no' => 1001, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subHour(), + 'close_time' => now()->subMinutes(45), + 'draw_time' => now()->subMinutes(30), + 'cooling_end_time' => null, + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ], '4'); + + $this->getJson('/api/v1/draw/results/20260509-1001') + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.draw_no', '20260509-1001') + ->assertJsonPath('data.results.1st', '4444'); +}); + test('draw results include all published provider batches for the same draw', function (): void { BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]); BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]); diff --git a/tests/Feature/DrawSettlementRunApiTest.php b/tests/Feature/DrawSettlementRunApiTest.php new file mode 100644 index 0000000..c21e46a --- /dev/null +++ b/tests/Feature/DrawSettlementRunApiTest.php @@ -0,0 +1,158 @@ +create([ + 'draw_no' => '20260722-'.random_int(1000, 9999), + 'business_date' => '2026-07-22', + 'sequence_no' => random_int(1000, 9999), + 'status' => $status, + 'start_time' => now()->subMinutes(20), + 'close_time' => now()->subMinutes(10), + 'draw_time' => now()->subMinutes(9), + 'cooling_end_time' => now()->addMinutes(10), + 'current_result_version' => $withPublishedResult ? 1 : 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + if ($withPublishedResult) { + DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'provider_code' => 'SG', + 'provider_name' => 'Singapore', + 'result_version' => 1, + 'source_type' => 'rng', + 'status' => DrawResultBatchStatus::Published->value, + 'confirmed_at' => now(), + ]); + } + + return $draw; +} + +function manualSettlementAdminToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'manual_settle_'.bin2hex(random_bytes(3)), + 'name' => 'Manual Settlement', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('payout manager may skip cooldown and settlement retry is idempotent', function (): void { + $draw = createDrawForManualSettlement(); + $originalCoolingEnd = $draw->cooling_end_time; + $token = manualSettlementAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run") + ->assertOk() + ->assertJsonPath('data.ran', true) + ->assertJsonPath('data.cooldown_skipped', true) + ->assertJsonPath('data.status', DrawStatus::Settling->value) + ->assertJsonPath('data.settle_version', 1); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Settling->value) + ->and($draw->cooling_end_time)->not->toBeNull() + ->and($draw->cooling_end_time->lessThan($originalCoolingEnd))->toBeTrue() + ->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run") + ->assertOk() + ->assertJsonPath('data.ran', true) + ->assertJsonPath('data.cooldown_skipped', false) + ->assertJsonPath('data.settle_version', 1); + + expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); +}); + +test('manual settlement rejects missing results and invalid draw states without mutation', function (): void { + $token = manualSettlementAdminToken(); + $missingResult = createDrawForManualSettlement(withPublishedResult: false); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$missingResult->id}/settlement/run") + ->assertStatus(409) + ->assertJsonPath('data.reason', 'draw_result_not_published'); + + expect($missingResult->fresh()->status)->toBe(DrawStatus::Cooldown->value) + ->and(SettlementBatch::query()->where('draw_id', $missingResult->id)->exists())->toBeFalse(); + + $openDraw = createDrawForManualSettlement(status: DrawStatus::Open->value); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$openDraw->id}/settlement/run") + ->assertStatus(409) + ->assertJsonPath('data.reason', 'draw_not_ready_for_settlement'); + + expect($openDraw->fresh()->status)->toBe(DrawStatus::Open->value) + ->and(SettlementBatch::query()->where('draw_id', $openDraw->id)->exists())->toBeFalse(); +}); + +test('manual settlement resource is restricted to settlement management and audited', function (): void { + $resource = collect(AdminAuthorizationRegistry::resources()) + ->firstWhere('code', 'admin.draws.settlement.run'); + + expect($resource)->not->toBeNull() + ->and($resource['permission_codes'])->toBe(['settlement.batch.manage']) + ->and($resource['is_audit_required'])->toBeTrue(); + + $draw = createDrawForManualSettlement(); + $token = manualSettlementAdminToken(); + $before = AuditLog::query()->count(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run") + ->assertOk(); + + expect(AuditLog::query()->count())->toBe($before + 1); +}); + +test('draw finance summary exposes provider and result settlement versions', function (): void { + $draw = createDrawForManualSettlement(status: DrawStatus::Settling->value); + $result = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $result->id, + 'settle_version' => 3, + 'status' => 'pending_review', + 'total_ticket_count' => 0, + 'total_win_count' => 0, + 'total_payout_amount' => 0, + 'total_jackpot_payout_amount' => 0, + 'started_at' => now(), + 'finished_at' => now(), + ]); + $token = manualSettlementAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson("/api/v1/admin/draws/{$draw->id}/finance-summary") + ->assertOk() + ->assertJsonPath('data.settlement_batches.0.provider_code', 'SG') + ->assertJsonPath('data.settlement_batches.0.provider_name', 'Singapore') + ->assertJsonPath('data.settlement_batches.0.result_version', 1) + ->assertJsonPath('data.settlement_batches.0.settle_version', 3); +}); diff --git a/tests/Feature/PlayerNativeAuthTest.php b/tests/Feature/PlayerNativeAuthTest.php index 3f13519..fbf10af 100644 --- a/tests/Feature/PlayerNativeAuthTest.php +++ b/tests/Feature/PlayerNativeAuthTest.php @@ -10,8 +10,10 @@ use Illuminate\Support\Facades\DB; use Database\Seeders\CurrencySeeder; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Event; use Database\Seeders\LotterySettingsSeeder; use Illuminate\Support\Facades\RateLimiter; +use App\Events\PlayerSessionReplacedBroadcast; use App\Services\Player\PlayerNativeAuthService; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -244,6 +246,63 @@ test('native player can login and access me', function (): void { ->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE); }); +test('latest native login replaces the previous device session', function (): void { + Event::fake([PlayerSessionReplacedBroadcast::class]); + + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:single-session', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'single_session_player', + 'password_hash' => Hash::make('secret-pass'), + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $firstLogin = $this->postJson('/api/v1/player/auth/login', array_merge([ + 'username' => 'single_session_player', + 'password' => 'secret-pass', + ], playerLoginCaptcha()))->assertOk(); + $firstToken = (string) $firstLogin->json('data.access_token'); + + expect($player->fresh()->native_session_version)->toBe(1); + $this->withHeader('Authorization', 'Bearer '.$firstToken) + ->getJson('/api/v1/player/me') + ->assertOk(); + + $secondLogin = $this->postJson('/api/v1/player/auth/login', array_merge([ + 'username' => 'single_session_player', + 'password' => 'secret-pass', + ], playerLoginCaptcha()))->assertOk(); + $secondToken = (string) $secondLogin->json('data.access_token'); + + expect($secondToken)->not->toBe($firstToken) + ->and($player->fresh()->native_session_version)->toBe(2); + + $this->withHeader('Authorization', 'Bearer '.$firstToken) + ->getJson('/api/v1/player/me') + ->assertStatus(401) + ->assertJsonPath('code', ErrorCode::PlayerSessionReplaced->value); + + $this->withHeader('Authorization', 'Bearer '.$secondToken) + ->getJson('/api/v1/player/me') + ->assertOk() + ->assertJsonPath('data.id', $player->id); + + Event::assertDispatchedTimes(PlayerSessionReplacedBroadcast::class, 2); + Event::assertDispatched( + PlayerSessionReplacedBroadcast::class, + fn (PlayerSessionReplacedBroadcast $event): bool => $event->playerId === (int) $player->id + && $event->sessionVersion === 2, + ); +}); + test('credit player wallet transfer in is rejected', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first(); $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); diff --git a/tests/Feature/PlayerRealtimeBroadcastTest.php b/tests/Feature/PlayerRealtimeBroadcastTest.php index 70f4b45..1e23d49 100644 --- a/tests/Feature/PlayerRealtimeBroadcastTest.php +++ b/tests/Feature/PlayerRealtimeBroadcastTest.php @@ -17,6 +17,7 @@ use App\Events\PlayCatalogUpdatedBroadcast; use Database\Seeders\LotterySettingsSeeder; use Illuminate\Broadcasting\PrivateChannel; use App\Services\Config\RiskCapStreamService; +use App\Events\PlayerSessionReplacedBroadcast; use App\Services\Wallet\LotteryTransferService; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Services\Wallet\WalletBalanceRealtimeNotifier; @@ -39,6 +40,21 @@ test('balance update broadcasts only on the player private channel', function () ->and($channels[0]->name)->toBe('private-player.42'); }); +test('session replacement broadcasts the monotonic version on the player private channel', function (): void { + $event = new PlayerSessionReplacedBroadcast(42, 3, 1_234_567); + $channels = $event->broadcastOn(); + + expect($channels)->toHaveCount(1) + ->and($channels[0])->toBeInstanceOf(PrivateChannel::class) + ->and($channels[0]->name)->toBe('private-player.42') + ->and($event->broadcastAs())->toBe('session.replaced') + ->and($event->broadcastWith())->toBe([ + 'player_id' => 42, + 'session_version' => 3, + 'emitted_at_ms' => 1_234_567, + ]); +}); + test('player private channel auth allows only the matching bearer player', function (): void { config([ 'broadcasting.default' => 'reverb', diff --git a/tests/Feature/SettlementCreditAndReportFixesTest.php b/tests/Feature/SettlementCreditAndReportFixesTest.php index a54d0e6..e62ff34 100644 --- a/tests/Feature/SettlementCreditAndReportFixesTest.php +++ b/tests/Feature/SettlementCreditAndReportFixesTest.php @@ -2,6 +2,7 @@ use App\Models\Draw; use App\Models\Player; +use App\Models\AuditLog; use App\Models\AdminRole; use App\Models\AdminUser; use App\Models\TicketItem; @@ -444,9 +445,24 @@ test('settlement tick finalizer keeps approved batch retryable when payout throw expect($result['payout_failed'])->toBe(1) ->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value) - ->and($batch->fresh()->review_remark)->toContain('auto_payout_failed'); + ->and($batch->fresh()->review_remark)->toContain('auto_payout_failed') + ->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1) + ->and(AuditLog::query() + ->where('action_code', 'auto_payout_failed') + ->where('target_id', (string) $batch->id) + ->count())->toBe(1); + + $immediateRetry = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + + expect($immediateRetry)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 0]) + ->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1) + ->and(AuditLog::query() + ->where('action_code', 'auto_payout_failed') + ->where('target_id', (string) $batch->id) + ->count())->toBe(1); DB::table('ticket_items')->where('id', $orphanItemId)->delete(); + $this->travel((int) config('lottery.auto_payout_retry_base_seconds'))->seconds(); $retry = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) diff --git a/tests/Feature/TicketItemsApiTest.php b/tests/Feature/TicketItemsApiTest.php index a837b08..8f55470 100644 --- a/tests/Feature/TicketItemsApiTest.php +++ b/tests/Feature/TicketItemsApiTest.php @@ -2,24 +2,26 @@ use App\Models\Draw; use App\Models\Player; +use App\Models\AdminUser; +use App\Models\TicketItem; use App\Lottery\DrawStatus; use App\Models\JackpotPool; +use App\Models\TicketOrder; use App\Models\PlayerWallet; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; -use App\Models\TicketOrder; -use App\Models\AdminUser; use App\Models\SettlementBatch; -use App\Services\Draw\DrawPrizeLayout; -use App\Services\Settlement\SettlementOrchestrator; -use App\Services\Settlement\SettlementBatchWorkflowService; +use App\Models\TicketCombination; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; -use App\Lottery\DrawResultBatchStatus; use Illuminate\Support\Facades\Hash; +use App\Lottery\DrawResultBatchStatus; +use App\Services\Draw\DrawPrizeLayout; use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\OperationalConfigV1Seeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use App\Services\Settlement\SettlementOrchestrator; +use App\Services\Settlement\SettlementBatchWorkflowService; uses(RefreshDatabase::class); @@ -171,6 +173,12 @@ test('ticket items index returns placed ticket for player', function (): void { ->assertJsonPath('data.ticket_no', $ticketNo) ->assertJsonPath('data.combinations.0.number_4d', '1234'); + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items?ticket_no='.urlencode($ticketNo)) + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.ticket_no', $ticketNo); + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777')) ->assertOk() @@ -313,7 +321,7 @@ test('ticket item show returns match result and timeline', function (): void { ticketItemsPublishAndSettle($draw, '1234'); - $ticketNo = \App\Models\TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no'); + $ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items/'.$ticketNo) @@ -348,9 +356,9 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct ]); $draw = Draw::query()->create([ - 'draw_no' => '20260511-778', + 'draw_no' => '20260511-1778', 'business_date' => '2026-05-11', - 'sequence_no' => 778, + 'sequence_no' => 1778, 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(2), 'close_time' => now()->addMinutes(5), @@ -364,7 +372,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ - 'draw_id' => '20260511-778', + 'draw_id' => '20260511-1778', 'currency_code' => 'NPR', 'client_trace_id' => 'match-trace-1', 'lines' => [ @@ -376,7 +384,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct ticketItemsPublishAndSettle($draw, '1234'); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) - ->getJson('/api/v1/ticket/draws/20260511-778/my-match') + ->getJson('/api/v1/ticket/draws/20260511-1778/my-match') ->assertOk() ->assertJsonPath('data.has_bets', true) ->assertJsonPath('data.winning_ticket_count', 1) @@ -441,7 +449,7 @@ test('my-match only highlights settled winning tickets', function (): void { 'client_trace_id' => 'pending-match', ]); - $item = \App\Models\TicketItem::query()->create([ + $item = TicketItem::query()->create([ 'ticket_no' => 'TKPENDINGMATCH', 'order_id' => $order->id, 'player_id' => $player->id, @@ -467,7 +475,7 @@ test('my-match only highlights settled winning tickets', function (): void { 'jackpot_win_amount' => 0, ]); - \App\Models\TicketCombination::query()->create([ + TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => 0, 'number_4d' => '1234', diff --git a/tests/Pest.php b/tests/Pest.php index 02d2f26..39f8f91 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -19,6 +19,7 @@ use App\Contracts\WalletApiDnsResolver; use App\Events\DrawStatusChangeBroadcast; use App\Events\PlayCatalogUpdatedBroadcast; use App\Events\DrawResultPublishedBroadcast; +use App\Events\PlayerSessionReplacedBroadcast; use App\Support\Integration\WalletApiRequestGuard; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -208,6 +209,7 @@ function allBroadcastEvents(): array OddsUpdateBroadcast::class, PlayCatalogUpdatedBroadcast::class, PlayToggleBroadcast::class, + PlayerSessionReplacedBroadcast::class, RiskSoldOutBroadcast::class, RiskWarningBroadcast::class, ];