From a60ce8caadd82037a9e1c0c7077769beb14214ec Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 27 May 2026 16:51:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=20DrawHallSnapsh?= =?UTF-8?q?otBuilder=20=E5=92=8C=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 DrawHallSnapshotBuilder 中简化数据获取逻辑,仅保留必要字段,更新状态表示方式。 - 在 AdminAuthorizationRegistry 中整合接入站点权限定义,提升权限管理的灵活性与可维护性。 - 更新调度任务配置,确保任务在单一服务器上运行,避免重叠执行,提高系统稳定性。 - 增强测试用例,确保新逻辑的正确性与稳定性。 --- .../CheckAdminPermissionLanguageCommand.php | 163 ++++++++++++++++++ app/Services/Draw/DrawHallSnapshotBuilder.php | 11 +- app/Support/AdminAuthorizationRegistry.php | 10 +- app/Support/AdminPermissionLanguage.php | 126 ++++++++++++++ bootstrap/app.php | 16 +- config/admin_permission_language.php | 48 ++++++ tests/Feature/AdminAuthLoginTest.php | 6 +- tests/Feature/AdminPlayerManageApiTest.php | 17 +- tests/Feature/AdminUserPermissionApiTest.php | 16 +- tests/Feature/DrawPipelineTest.php | 53 ++++++ 10 files changed, 434 insertions(+), 32 deletions(-) create mode 100644 app/Console/Commands/CheckAdminPermissionLanguageCommand.php create mode 100644 app/Support/AdminPermissionLanguage.php create mode 100644 config/admin_permission_language.php diff --git a/app/Console/Commands/CheckAdminPermissionLanguageCommand.php b/app/Console/Commands/CheckAdminPermissionLanguageCommand.php new file mode 100644 index 0000000..b5cf551 --- /dev/null +++ b/app/Console/Commands/CheckAdminPermissionLanguageCommand.php @@ -0,0 +1,163 @@ +option('page'); + if ($pageKey === '') { + $this->error('Missing --page'); + return self::FAILURE; + } + + $issues = []; + + $requiredPrdSlugs = AdminPermissionLanguage::requiredAnyPrdSlugs($pageKey); + if ($requiredPrdSlugs === []) { + $issues[] = [ + 'type' => 'config', + 'message' => sprintf('No required prd slugs found for page `%s`.', $pageKey), + ]; + } + + $permissionDefinitions = AdminAuthorizationRegistry::permissionDefinitions(); + + /** @var array}> $bySlug */ + $bySlug = []; + foreach ($permissionDefinitions as $def) { + $slug = $def['slug'] ?? ''; + if (! is_string($slug) || $slug === '') { + continue; + } + $bySlug[$slug] = $def; + } + + foreach (AdminPermissionLanguage::requiredBundleKeys($pageKey) as $bundleKey) { + $expectedSlug = AdminPermissionLanguage::prdSlug($pageKey, $bundleKey); + $expectedCodes = AdminPermissionLanguage::permissionCodes($pageKey, $bundleKey); + + if (! isset($bySlug[$expectedSlug])) { + $issues[] = [ + 'type' => 'prd_slug', + 'message' => sprintf('PRD slug `%s` for bundle `%s` not found in AdminAuthorizationRegistry::permissionDefinitions().', $expectedSlug, $bundleKey), + ]; + continue; + } + + $actualCodes = $bySlug[$expectedSlug]['permission_codes'] ?? []; + $actualSet = array_fill_keys(is_array($actualCodes) ? $actualCodes : [], true); + $expectedSet = array_fill_keys($expectedCodes, true); + + $missing = array_values(array_diff(array_keys($expectedSet), array_keys($actualSet))); + if ($missing !== []) { + $issues[] = [ + 'type' => 'prd_action_codes', + 'message' => sprintf( + 'PRD slug `%s` (bundle `%s`) missing action codes: %s', + $expectedSlug, + $bundleKey, + implode(', ', $missing), + ), + ]; + } + } + + // Sidebar / “页面可进”入口:integration-sites 对应 nav_segment=integration + foreach (AdminAuthorizationRegistry::navigationDefinitions() as $nav) { + if (($nav['segment'] ?? '') !== 'integration') { + continue; + } + + $requiredAny = $nav['requiredAny'] ?? []; + if (! is_array($requiredAny)) { + $requiredAny = []; + } + + foreach ($requiredPrdSlugs as $requiredSlug) { + if (! in_array($requiredSlug, $requiredAny, true)) { + $issues[] = [ + 'type' => 'navigation_requiredAny', + 'message' => sprintf('Navigation segment `integration` missing requiredAny slug `%s`.', $requiredSlug), + ]; + } + } + + break; + } + + // API 资源:检查 integration-sites 的路由资源 binding 至少包含对应 action code。 + $resources = AdminAuthorizationRegistry::resources(); + /** @var array}> $resourceByCode */ + $resourceByCode = []; + foreach ($resources as $resource) { + $code = $resource['code'] ?? ''; + if (! is_string($code) || $code === '') { + continue; + } + $resourceByCode[$code] = $resource; + } + + $viewActionCodes = AdminPermissionLanguage::permissionCodes($pageKey, 'view'); + $manageActionCodes = AdminPermissionLanguage::permissionCodes($pageKey, 'manage'); + + $endpointChecks = [ + // view + 'admin.integration-sites.index' => ['expected_permission_codes' => $viewActionCodes, 'expected_bundle' => 'view'], + 'admin.integration-sites.show' => ['expected_permission_codes' => $viewActionCodes, 'expected_bundle' => 'view'], + 'admin.integration-sites.connectivity-test' => ['expected_permission_codes' => $viewActionCodes, 'expected_bundle' => 'view'], + 'admin.integration-sites.export' => ['expected_permission_codes' => $viewActionCodes, 'expected_bundle' => 'view'], + + // manage + 'admin.integration-sites.store' => ['expected_permission_codes' => $manageActionCodes, 'expected_bundle' => 'manage'], + 'admin.integration-sites.update' => ['expected_permission_codes' => $manageActionCodes, 'expected_bundle' => 'manage'], + 'admin.integration-sites.rotate-secrets' => ['expected_permission_codes' => $manageActionCodes, 'expected_bundle' => 'manage'], + ]; + + foreach ($endpointChecks as $resourceCode => $check) { + if (! isset($resourceByCode[$resourceCode])) { + $issues[] = [ + 'type' => 'resource_definitions', + 'message' => sprintf('API resource `%s` not found in AdminAuthorizationRegistry::resources().', $resourceCode), + ]; + continue; + } + + $expectedCodes = $check['expected_permission_codes'] ?? []; + $resourcePermissionCodes = $resourceByCode[$resourceCode]['permission_codes'] ?? []; + + $resourceSet = array_fill_keys(is_array($resourcePermissionCodes) ? $resourcePermissionCodes : [], true); + foreach ($expectedCodes as $expectedCode) { + if (! isset($resourceSet[$expectedCode])) { + $issues[] = [ + 'type' => 'api_resource_action_codes', + 'message' => sprintf('API resource `%s` missing action code `%s`.', $resourceCode, $expectedCode), + ]; + } + } + } + + if ($issues === []) { + $this->info('Admin permission language check passed.'); + return self::SUCCESS; + } + + $this->error(sprintf('Admin permission language check found %d issue(s).', count($issues))); + foreach ($issues as $issue) { + $this->line(sprintf('- [%s] %s', $issue['type'], $issue['message'])); + } + + return self::FAILURE; + } +} + diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index f3008d9..97f8727 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -270,17 +270,10 @@ final class DrawHallSnapshotBuilder ->orderByDesc('locked_amount') ->orderBy('normalized_number') ->limit(500) - ->get(['normalized_number', 'total_cap_amount', 'locked_amount', 'remaining_amount', 'sold_out_status']) + ->get(['normalized_number', 'sold_out_status']) ->map(fn ($row) => [ 'normalized_number' => (string) $row->normalized_number, - 'total_cap_amount' => (int) $row->total_cap_amount, - 'locked_amount' => (int) $row->locked_amount, - 'remaining_amount' => (int) $row->remaining_amount, - 'sold_out_status' => (int) $row->sold_out_status, - 'is_sold_out' => (int) $row->sold_out_status === 1, - 'usage_ratio' => (int) $row->total_cap_amount > 0 - ? round(((int) $row->locked_amount) / (int) $row->total_cap_amount, 6) - : null, + 'status' => (int) $row->sold_out_status === 1 ? 'sold_out' : 'warning', ]) ->values() ->all(); diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index eb3c28a..d4ddb53 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -27,13 +27,13 @@ final class AdminAuthorizationRegistry ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']], ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']], - ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view']], + ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.tickets.view']], ['slug' => 'prd.tickets.view', 'name' => '玩家注单·查看', 'nav_segment' => 'tickets', 'permission_codes' => ['service.tickets.view']], ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']], ['slug' => 'prd.currency.manage', 'name' => '币种管理·可管理', 'nav_segment' => 'settings', 'permission_codes' => ['service.currency.manage']], - ['slug' => 'prd.integration.view', 'name' => '接入站点·查看', 'nav_segment' => 'integration', 'permission_codes' => ['integration.site.view']], - ['slug' => 'prd.integration.manage', 'name' => '接入站点·可管理', 'nav_segment' => 'integration', 'permission_codes' => ['integration.site.manage']], + ['slug' => AdminPermissionLanguage::prdSlug('integration-sites', 'view'), 'name' => AdminPermissionLanguage::prdName('integration-sites', 'view'), 'nav_segment' => 'integration', 'permission_codes' => AdminPermissionLanguage::permissionCodes('integration-sites', 'view')], + ['slug' => AdminPermissionLanguage::prdSlug('integration-sites', 'manage'), 'name' => AdminPermissionLanguage::prdName('integration-sites', 'manage'), 'nav_segment' => 'integration', 'permission_codes' => AdminPermissionLanguage::permissionCodes('integration-sites', 'manage')], ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust']], ['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], @@ -139,7 +139,7 @@ final class AdminAuthorizationRegistry ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.report.view']], ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']], - ['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => ['prd.integration.view', 'prd.integration.manage']], + ['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')], // 权限与系统 ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']], @@ -217,7 +217,7 @@ final class AdminAuthorizationRegistry 'tickets' => ['prd.tickets.view'], 'audit' => ['prd.audit.view'], 'settings' => ['prd.wallet_reconcile.manage', 'prd.currency.manage'], - 'integration' => ['prd.integration.view', 'prd.integration.manage'], + 'integration' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'), ]; if (isset($explicit[$segment])) { diff --git a/app/Support/AdminPermissionLanguage.php b/app/Support/AdminPermissionLanguage.php new file mode 100644 index 0000000..bed5c76 --- /dev/null +++ b/app/Support/AdminPermissionLanguage.php @@ -0,0 +1,126 @@ + */ + public static function permissionCodes(string $pageKey, string $bundleKey): array + { + $pages = self::pages(); + $page = $pages[$pageKey] ?? []; + $bundle = $page['bundles'][$bundleKey] ?? []; + $codes = is_array($bundle) ? ($bundle['permission_codes'] ?? []) : []; + + return is_array($codes) ? array_values(array_filter($codes, static fn ($v): bool => is_string($v) && $v !== '')) : []; + } + + public static function prdName(string $pageKey, string $bundleKey): string + { + return sprintf('%s·%s', self::pageLabel($pageKey), self::bundleLabel($pageKey, $bundleKey)); + } + + /** @return list */ + public static function requiredBundleKeys(string $pageKey): array + { + $pages = self::pages(); + $page = $pages[$pageKey] ?? []; + $required = $page['required_bundles'] ?? []; + if (! is_array($required)) { + return []; + } + + return array_values(array_filter($required, static fn ($v): bool => is_string($v) && $v !== '')); + } + + /** @return list */ + public static function requiredAnyPrdSlugs(string $pageKey): array + { + $bundleKeys = self::requiredBundleKeys($pageKey); + if ($bundleKeys === []) { + return []; + } + + $slugs = []; + foreach ($bundleKeys as $bundleKey) { + $slug = self::prdSlug($pageKey, $bundleKey); + if (is_string($slug) && $slug !== '') { + $slugs[] = $slug; + } + } + + return array_values(array_unique($slugs)); + } +} + diff --git a/bootstrap/app.php b/bootstrap/app.php index b2471fa..701516b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -170,16 +170,24 @@ return Application::configure(basePath: dirname(__DIR__)) }) ->withSchedule(function (Schedule $schedule): void { /** 开奖时刻后尽快跑 RNG/冷静期,避免大厅在 0:00 卡住最多 1 分钟 */ - $schedule->command('lottery:draw-tick')->everyTenSeconds(); + $schedule->command('lottery:draw-tick') + ->everyTenSeconds() + ->withoutOverlapping() + ->onOneServer(); $schedule->command('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=1000') ->everyTenMinutes() - ->withoutOverlapping(); + ->withoutOverlapping() + ->onOneServer(); $schedule->command('lottery:ticket-pending-confirm-reconcile --stale-minutes=5 --limit=500') ->everyMinute() - ->withoutOverlapping(); + ->withoutOverlapping() + ->onOneServer(); /** @see docs/01-界面文档.md §2.1 `draw.countdown` */ if (config('lottery.realtime_hall_countdown', true)) { - $schedule->command('lottery:hall-countdown')->everySecond(); + $schedule->command('lottery:hall-countdown') + ->everySecond() + ->withoutOverlapping() + ->onOneServer(); } }) ->create(); diff --git a/config/admin_permission_language.php b/config/admin_permission_language.php new file mode 100644 index 0000000..6ac6d15 --- /dev/null +++ b/config/admin_permission_language.php @@ -0,0 +1,48 @@ + [ + 'view' => [ + 'label' => '查看', + ], + 'manage' => [ + 'label' => '可管理', + ], + 'audit' => [ + 'label' => '可审核', + ], + 'export' => [ + 'label' => '导出', + ], + 'privilege' => [ + 'label' => '特权', + ], + ], + + // “页面权限包”:把页面动作映射到 `prd.*` slug 与运行时 action code + 'pages' => [ + 'integration-sites' => [ + 'label' => '接入站点', + // 该页面在侧栏/页级入口需要的最小 slug 集合(对应“页面可进”) + 'required_bundles' => [ + 'view', + 'manage', + ], + 'bundles' => [ + 'view' => [ + 'category' => 'view', + 'prd_slug' => 'prd.integration.view', + 'permission_codes' => ['integration.site.view'], + ], + 'manage' => [ + 'category' => 'manage', + 'prd_slug' => 'prd.integration.manage', + 'permission_codes' => ['integration.site.manage'], + ], + // 当前页面暂无对“审核/导出/特权”的独立权限包拆分 + ], + ], + ], +]; + diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 84d645d..5eabe08 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -61,13 +61,15 @@ test('admin auth me returns current admin profile', function () { }); test('admin login returns bearer token when captcha passes validation', function () { - AdminUser::query()->create([ + $admin = AdminUser::query()->create([ 'username' => 'tester', 'name' => '测试昵称', 'email' => null, 'password' => 'secret-strong', 'status' => 0, ]); + // 登录返回的 navigation 需要管理员权限(避免测试依赖默认 DB 状态) + grantSuperAdminRole($admin); $captchaKey = (string) Str::uuid(); Cache::put( @@ -89,7 +91,7 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('data.admin.nickname', '测试昵称') ->assertJsonPath('data.admin.navigation.0.segment', 'dashboard') ->assertJsonPath('data.admin.navigation.0.href', '/admin') - ->assertJsonPath('data.admin.navigation.1.segment', 'settings') + ->assertJsonPath('data.admin.navigation.1.segment', 'draws') ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'navigation']]]); $token = $resp->json('data.token'); diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index 36408f5..a5e2680 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -7,6 +7,7 @@ use App\Models\AdminUser; use App\Models\PlayerWallet; use Database\Seeders\CurrencySeeder; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\DB; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -63,8 +64,11 @@ function playerPermissionRequest($test, string $token) } test('admin can freeze and unfreeze player with audit log', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + $player = Player::query()->create([ - 'site_code' => 'main', + 'site_code' => $siteCode, 'site_player_id' => 'freeze-1', 'username' => 'freeze_user', 'nickname' => 'Freeze', @@ -105,8 +109,11 @@ test('admin can freeze and unfreeze player with audit log', function (): void { }); test('player manage permission gates write and freeze APIs separately from view permissions', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + $player = Player::query()->create([ - 'site_code' => 'main', + 'site_code' => $siteCode, 'site_player_id' => 'perm-1', 'username' => 'perm_user', 'nickname' => 'Perm', @@ -164,9 +171,9 @@ test('player manage permission gates write and freeze APIs separately from view ->getJson('/api/v1/admin/players?per_page=10') ->assertForbidden(); - playerPermissionRequest($this, $freezeToken) - ->postJson('/api/v1/admin/players/'.$player->id.'/freeze') - ->assertOk() + $freezeResp = playerPermissionRequest($this, $freezeToken) + ->postJson('/api/v1/admin/players/'.$player->id.'/freeze'); + $freezeResp->assertOk() ->assertJsonPath('data.status', 1); playerPermissionRequest($this, $freezeToken) diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 7c52e0a..cbd2afd 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -147,6 +147,7 @@ test('permission catalog groups permissions by admin navigation order', function ->json('data.permission_menu_groups'); expect(array_column($groups, 'key'))->toBe([ + 'dashboard', 'draws', 'tickets', 'players', @@ -159,21 +160,22 @@ test('permission catalog groups permissions by admin navigation order', function 'reconcile', 'reports', 'currencies', + 'integration', 'admin_users', 'admin_roles', 'risk', 'audit', + 'settings', ]); - expect($groups[0]['key'])->toBe('draws'); - expect($groups[12]['label'])->toBe('管理列表'); - expect(array_column($groups[12]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); - expect($groups[13]['label'])->toBe('角色管理'); - expect(array_column($groups[13]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); + expect($groups[1]['key'])->toBe('draws'); + expect($groups[14]['label'])->toBe('管理列表'); + expect(array_column($groups[14]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); + expect($groups[15]['label'])->toBe('角色管理'); + expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); $groupsByKey = collect($groups)->keyBy('key'); expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([ - 'prd.users.view_cs', - 'prd.users.manage', + 'prd.tickets.view', ]); expect(array_column($groupsByKey['reports']['permissions'], 'slug'))->toContain( 'prd.report.view', diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index 51f7a73..5f24153 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -5,6 +5,7 @@ use App\Models\Draw; use App\Models\Player; use App\Models\AdminRole; use App\Models\AdminUser; +use App\Models\RiskPool; use App\Models\TicketItem; use App\Models\TicketOrder; use App\Models\PlayerWallet; @@ -895,6 +896,58 @@ test('GET draw current returns open draw with seconds to close', function (): vo Carbon::setTestNow(); }); +test('GET draw current only exposes coarse risk alert status', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 15:00:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-301', + 'business_date' => '2026-05-09', + 'sequence_no' => 301, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->copy()->subMinutes(5), + 'close_time' => now()->copy()->addMinutes(30), + 'draw_time' => now()->copy()->addHour(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 1_000, + 'locked_amount' => 850, + 'remaining_amount' => 150, + 'sold_out_status' => 0, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '5678', + 'total_cap_amount' => 100, + 'locked_amount' => 100, + 'remaining_amount' => 0, + 'sold_out_status' => 1, + ]); + + $this->getJson('/api/v1/draw/current') + ->assertOk() + ->assertJsonPath('data.data.draw_no', '20260509-301') + ->assertJsonPath('data.data.risk_pool_alerts.0.normalized_number', '5678') + ->assertJsonPath('data.data.risk_pool_alerts.0.status', 'sold_out') + ->assertJsonPath('data.data.risk_pool_alerts.1.normalized_number', '1234') + ->assertJsonPath('data.data.risk_pool_alerts.1.status', 'warning') + ->assertJsonMissingPath('data.data.risk_pool_alerts.0.total_cap_amount') + ->assertJsonMissingPath('data.data.risk_pool_alerts.0.locked_amount') + ->assertJsonMissingPath('data.data.risk_pool_alerts.0.remaining_amount') + ->assertJsonMissingPath('data.data.risk_pool_alerts.0.sold_out_status') + ->assertJsonMissingPath('data.data.risk_pool_alerts.0.usage_ratio'); + + Carbon::setTestNow(); +}); + test('GET draw current exposes closing when row is open in DB but close_time has passed', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC')); $drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');