refactor: 优化 DrawHallSnapshotBuilder 和权限管理逻辑

- 在 DrawHallSnapshotBuilder 中简化数据获取逻辑,仅保留必要字段,更新状态表示方式。
- 在 AdminAuthorizationRegistry 中整合接入站点权限定义,提升权限管理的灵活性与可维护性。
- 更新调度任务配置,确保任务在单一服务器上运行,避免重叠执行,提高系统稳定性。
- 增强测试用例,确保新逻辑的正确性与稳定性。
This commit is contained in:
2026-05-27 16:51:08 +08:00
parent a10135d6ee
commit a60ce8caad
10 changed files with 434 additions and 32 deletions

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Console\Commands;
use App\Support\AdminAuthorizationRegistry;
use App\Support\AdminPermissionLanguage;
use Illuminate\Console\Command;
final class CheckAdminPermissionLanguageCommand extends Command
{
protected $signature = 'lottery:admin-permission-language-check
{--page=integration-sites : 页面 key当前只验证 integration-sites 示例映射)}';
protected $description = '检查“权限语言映射”与后端 Registry / API 资源绑定的一致性';
public function handle(): int
{
$pageKey = (string) $this->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<string, array{slug: string, permission_codes: list<string>}> $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<string, array{permission_codes: list<string>}> $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;
}
}

View File

@@ -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();

View File

@@ -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])) {

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Support;
/**
* “权限语言”映射层:
* - UI 可理解的词汇(查看/可管理/可审核/导出/特权)映射到系统真实运行时校验的 `prd.*` slug
* - 以及进一步映射到运行时 action code `integration.site.view`
*
* 当前仅把 integration-sites 作为首个落地示例,其它页面建议按同样结构逐步接入。
*/
final class AdminPermissionLanguage
{
private static function cfg(): array
{
$cfg = config('admin_permission_language');
if (is_array($cfg)) {
return $cfg;
}
// 若存在 config cache 且未刷新新加配置文件未生效config() 可能拿不到该 key。
// 兜底 require 该 PHP 配置文件,避免 Registry 产出空 slug / 空 permission_codes。
$path = base_path('config/admin_permission_language.php');
if (is_string($path) && file_exists($path)) {
/** @var mixed $loaded */
$loaded = require $path;
return is_array($loaded) ? $loaded : [];
}
return [];
}
private static function categories(): array
{
return self::cfg()['categories'] ?? [];
}
private static function pages(): array
{
return self::cfg()['pages'] ?? [];
}
public static function pageLabel(string $pageKey): string
{
$pages = self::pages();
$page = $pages[$pageKey] ?? null;
$label = $page['label'] ?? '';
return is_string($label) ? $label : '';
}
public static function bundleCategoryKey(string $pageKey, string $bundleKey): string
{
$pages = self::pages();
$page = $pages[$pageKey] ?? [];
$bundle = $page['bundles'][$bundleKey] ?? [];
$category = is_array($bundle) ? ($bundle['category'] ?? '') : '';
return is_string($category) ? $category : '';
}
public static function bundleLabel(string $pageKey, string $bundleKey): string
{
$categoryKey = self::bundleCategoryKey($pageKey, $bundleKey);
$categories = self::categories();
$category = $categories[$categoryKey] ?? null;
$label = $category['label'] ?? '';
return is_string($label) ? $label : '';
}
public static function prdSlug(string $pageKey, string $bundleKey): string
{
$pages = self::pages();
$page = $pages[$pageKey] ?? [];
$bundle = $page['bundles'][$bundleKey] ?? [];
$slug = is_array($bundle) ? ($bundle['prd_slug'] ?? '') : '';
return is_string($slug) ? $slug : '';
}
/** @return list<string> */
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<string> */
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<string> */
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));
}
}

View File

@@ -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();

View File

@@ -0,0 +1,48 @@
<?php
return [
// 统一给 UI 展示的五类“产品词汇”
'categories' => [
'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'],
],
// 当前页面暂无对“审核/导出/特权”的独立权限包拆分
],
],
],
];

View File

@@ -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');

View File

@@ -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)

View File

@@ -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',

View File

@@ -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');