feat: 增强抽奖管理功能,支持手动创建、更新和删除期号

- 新增 API 路由和控制器,允许管理员手动创建、更新和删除抽奖期号。
- 更新抽奖调度逻辑,确保在抽奖时间和封盘时间的管理上更加灵活。
- 添加多语言支持的错误信息,提升用户体验。
- 更新测试用例,确保新功能的正确性和稳定性。
This commit is contained in:
2026-05-25 18:00:22 +08:00
parent 770fd8950d
commit c74bec3f64
21 changed files with 855 additions and 51 deletions

View File

@@ -72,6 +72,141 @@ test('admin can batch generate draw schedule buffer', function (): void {
Carbon::setTestNow();
});
test('draw planner schedules after last draw_time not midnight slot', function (): void {
config([
'lottery.draw.interval_minutes' => 5,
'lottery.draw.buffer_draws_ahead' => 2,
]);
$fixed = Carbon::parse('2026-05-25 11:00:00', 'UTC');
$lastId = Draw::query()->create([
'draw_no' => '20260525-120',
'business_date' => '2026-05-25',
'sequence_no' => 120,
'status' => DrawStatus::Settled->value,
'start_time' => Carbon::parse('2026-05-25 11:54:30', 'UTC'),
'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'),
'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
])->id;
app(DrawPlannerService::class)->ensureBuffer($fixed);
$next = Draw::query()->where('id', '>', $lastId)->orderBy('draw_time')->first();
expect($next)->not->toBeNull();
expect($next->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:05:00');
expect($next->sequence_no)->toBe(121);
});
test('admin can manually create draw with custom timeline', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC'));
$admin = AdminUser::query()->create([
'username' => 'draw_create_admin',
'name' => 'Draw Create Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/draws', [
'draw_time' => '2026-05-25 12:00:00',
'start_time' => '2026-05-25 11:55:00',
'close_time' => '2026-05-25 11:59:30',
'draw_no' => '20260525-901',
])
->assertCreated()
->assertJsonPath('data.draw_no', '20260525-901')
->assertJsonPath('data.status', DrawStatus::Pending->value);
$draw = Draw::query()->where('draw_no', '20260525-901')->first();
expect($draw)->not->toBeNull();
expect($draw->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:00:00');
Carbon::setTestNow();
});
test('admin can update pending draw timeline', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC'));
$admin = AdminUser::query()->create([
'username' => 'draw_update_admin',
'name' => 'Draw Update Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$draw = Draw::query()->create([
'draw_no' => '20260525-902',
'business_date' => '2026-05-25',
'sequence_no' => 902,
'status' => DrawStatus::Pending->value,
'start_time' => Carbon::parse('2026-05-25 11:55:00', 'UTC'),
'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'),
'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/draws/'.$draw->id, [
'draw_time' => '2026-05-25 13:00:00',
'start_time' => '2026-05-25 12:55:00',
'close_time' => '2026-05-25 12:59:30',
])
->assertOk()
->assertJsonPath('data.draw_time', fn ($v) => str_contains((string) $v, '13:00'));
Carbon::setTestNow();
});
test('admin can destroy pending draw without bets', function (): void {
$admin = AdminUser::query()->create([
'username' => 'draw_destroy_admin',
'name' => 'Draw Destroy Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$draw = Draw::query()->create([
'draw_no' => '20260525-903',
'business_date' => '2026-05-25',
'sequence_no' => 903,
'status' => DrawStatus::Pending->value,
'start_time' => now()->addHour(),
'close_time' => now()->addHours(2),
'draw_time' => now()->addHours(3),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/draws/'.$draw->id)
->assertOk()
->assertJsonPath('data.deleted', true);
expect(Draw::query()->find($draw->id))->toBeNull();
});
test('admin can manually close open draw', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 12:10:00', 'UTC'));
@@ -617,6 +752,7 @@ test('GET draw current returns open draw with seconds to close', function (): vo
$this->getJson('/api/v1/draw/current')
->assertOk()
->assertJsonPath('data.data.schedule_timezone', 'UTC')
->assertJsonPath('data.data.draw_no', '20260509-300')
->assertJsonPath('data.data.status', DrawStatus::Open->value)
->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30)
@@ -771,6 +907,50 @@ test('lottery hall-countdown dispatches draw.countdown when using reverb connect
);
});
test('hall snapshot skips stale pending draw and picks next upcoming row', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-25 18:00:00', 'UTC'));
Draw::query()->create([
'draw_no' => '20260525-999',
'business_date' => '2026-05-25',
'sequence_no' => 999,
'status' => DrawStatus::Pending->value,
'start_time' => Carbon::parse('2026-05-25 17:32:00', 'UTC'),
'close_time' => Carbon::parse('2026-05-25 17:36:30', 'UTC'),
'draw_time' => Carbon::parse('2026-05-25 17:37:00', 'UTC'),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
Draw::query()->create([
'draw_no' => '20260525-1006',
'business_date' => '2026-05-25',
'sequence_no' => 1006,
'status' => DrawStatus::Pending->value,
'start_time' => Carbon::parse('2026-05-25 18:07:00', 'UTC'),
'close_time' => Carbon::parse('2026-05-25 18:11:30', 'UTC'),
'draw_time' => Carbon::parse('2026-05-25 18:12:00', 'UTC'),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc());
expect($target)->not->toBeNull()
->and($target->draw_no)->toBe('20260525-1006');
$payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc());
expect($payload['draw_no'])->toBe('20260525-1006')
->and($payload['seconds_to_start'])->toBeGreaterThan(0);
Carbon::setTestNow();
});
test('hall snapshot switches to next bettable draw when cooldown ended', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC'));