feat: 添加 Laravel Reverb 支持,更新 .env.example 文件以配置 WebSocket,增强彩票调度功能,更新 API 路由以支持期号管理与结果发布

This commit is contained in:
2026-05-09 17:40:49 +08:00
parent 781cf10928
commit aeaf124096
42 changed files with 3886 additions and 5 deletions

View File

@@ -0,0 +1,144 @@
<?php
use App\Lottery\DrawResultBatchStatus;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
function mintAdminBearer(): string
{
$admin = AdminUser::query()->create([
'username' => 'draw_pages_admin',
'name' => 'Draw QA',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin draws index requires authentication', function (): void {
$this->getJson('/api/v1/admin/draws')->assertUnauthorized();
});
test('admin draws index returns pagination', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 12:00:00', 'UTC'));
Draw::query()->create([
'draw_no' => '20260509-001',
'business_date' => '2026-05-09',
'sequence_no' => 1,
'status' => 'pending',
'start_time' => now()->copy()->addHour(),
'close_time' => null,
'draw_time' => now()->copy()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$token = mintAdminBearer();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws?per_page=5')
->assertOk()
->assertJsonPath('data.meta.total', 1)
->assertJsonPath('data.items.0.draw_no', '20260509-001');
Carbon::setTestNow();
});
test('admin draw show exposes hall preview status', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC'));
$drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');
$closeTime = $drawTime->copy()->subSeconds(30);
$draw = Draw::query()->create([
'draw_no' => '20260509-802',
'business_date' => '2026-05-09',
'sequence_no' => 802,
'status' => 'open',
'start_time' => $closeTime->copy()->subMinutes(5),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$token = mintAdminBearer();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id)
->assertOk()
->assertJsonPath('data.draw_no', '20260509-802')
->assertJsonPath('data.status', 'open')
->assertJsonPath('data.hall_preview_status', 'closing');
Carbon::setTestNow();
});
test('admin draw result batches lists items', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 16:10:00', 'UTC'));
$draw = Draw::query()->create([
'draw_no' => '20260509-400',
'business_date' => '2026-05-09',
'sequence_no' => 400,
'status' => 'cooldown',
'start_time' => now()->copy()->subHour(),
'close_time' => now()->copy()->subMinutes(40),
'draw_time' => now()->copy()->subMinutes(20),
'cooling_end_time' => now()->copy()->addMinutes(10),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
]);
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'rng_seed_hash' => hash('sha256', 'x'),
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => 'first',
'prize_index' => 0,
'number_4d' => '1234',
'suffix_3d' => '234',
'suffix_2d' => '34',
'head_digit' => 1,
'tail_digit' => 4,
]);
$token = mintAdminBearer();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/result-batches')
->assertOk()
->assertJsonPath('data.draw_no', '20260509-400')
->assertJsonPath('data.batches.0.result_version', 1)
->assertJsonPath('data.batches.0.items.0.number_4d', '1234');
Carbon::setTestNow();
});

View File

@@ -0,0 +1,398 @@
<?php
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Events\DrawCountdownBroadcast;
use App\Events\DrawStatusChangeBroadcast;
use App\Models\DrawResultItem;
use App\Services\Draw\DrawPlannerService;
use App\Services\Draw\DrawTickService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'lottery.draw.timezone' => 'UTC',
'lottery.draw.interval_minutes' => 60,
'lottery.draw.buffer_draws_ahead' => 3,
'lottery.draw.betting_window_seconds' => 270,
'lottery.draw.close_before_draw_seconds' => 30,
'lottery.draw.require_manual_review' => false,
'lottery.draw.cooldown_minutes' => 15,
]);
});
test('draw planner fills buffer rows with ordered draw_no', function (): void {
$fixed = Carbon::parse('2026-05-09 12:00:00', 'UTC')->utc();
/** @var DrawPlannerService $planner */
$planner = app(DrawPlannerService::class);
$report = $planner->ensureBuffer($fixed);
expect($report['created'])->toBeGreaterThan(0);
expect(Draw::query()->count())->toBe($report['upcoming']);
$drawNos = Draw::query()->orderBy('draw_time')->pluck('draw_no')->all();
$sorted = $drawNos;
sort($sorted);
expect($drawNos)->toEqual($sorted);
});
test('draw tick moves open draw to closing when close_time passed before draw_time', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC'));
$drawTime = now()->copy()->addMinutes(30);
$closeTime = now()->copy()->subMinute();
Draw::query()->create([
'draw_no' => '20260509-099',
'business_date' => '2026-05-09',
'sequence_no' => 99,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(50),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
app(DrawTickService::class)->tick(now()->utc());
$draw = Draw::query()->where('draw_no', '20260509-099')->firstOrFail();
expect($draw->status)->toBe(DrawStatus::Closing->value);
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(0);
Carbon::setTestNow();
});
test('draw tick rng publishes result when manual review disabled', function (): void {
config(['lottery.draw.require_manual_review' => false]);
Carbon::setTestNow(Carbon::parse('2026-05-09 14:05:00', 'UTC'));
$drawTime = now()->copy()->subMinute();
$closeTime = $drawTime->copy()->subSeconds(30);
Draw::query()->create([
'draw_no' => '20260509-200',
'business_date' => '2026-05-09',
'sequence_no' => 200,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(10),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
app(DrawTickService::class)->tick(now()->utc());
$draw = Draw::query()->where('draw_no', '20260509-200')->firstOrFail();
expect($draw->status)->toBe(DrawStatus::Cooldown->value);
expect($draw->current_result_version)->toBe(1);
expect($draw->cooling_end_time)->not->toBeNull();
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
expect($batch->items()->count())->toBe(23);
Carbon::setTestNow();
});
test('draw tick rng awaits manual publish when review enabled', function (): void {
config(['lottery.draw.require_manual_review' => true]);
Carbon::setTestNow(Carbon::parse('2026-05-09 14:06:00', 'UTC'));
$drawTime = now()->copy()->subMinute();
$closeTime = $drawTime->copy()->subSeconds(30);
$drawRow = Draw::query()->create([
'draw_no' => '20260509-201',
'business_date' => '2026-05-09',
'sequence_no' => 201,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(10),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
app(DrawTickService::class)->tick(now()->utc());
$drawRow->refresh();
expect($drawRow->status)->toBe(DrawStatus::Review->value);
$batch = DrawResultBatch::query()->where('draw_id', $drawRow->id)->firstOrFail();
expect($batch->status)->toBe(DrawResultBatchStatus::PendingReview->value);
$admin = AdminUser::query()->create([
'username' => 'draw_auditor',
'name' => 'Auditor',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$batch->id}/publish")
->assertOk();
$drawRow->refresh();
$batch->refresh();
expect($drawRow->status)->toBe(DrawStatus::Cooldown->value);
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
expect($drawRow->current_result_version)->toBe(1);
expect($drawRow->cooling_end_time)->not->toBeNull();
Carbon::setTestNow();
});
test('cooldown expiry tick moves draw to settling', function (): void {
config([
'lottery.draw.require_manual_review' => false,
'lottery.draw.cooldown_minutes' => 15,
]);
Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:00', 'UTC'));
$drawTime = now()->copy()->subMinute();
$closeTime = $drawTime->copy()->subSeconds(30);
Draw::query()->create([
'draw_no' => '20260509-777',
'business_date' => '2026-05-09',
'sequence_no' => 777,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(10),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
app(DrawTickService::class)->tick(now()->utc());
$draw = Draw::query()->where('draw_no', '20260509-777')->firstOrFail();
expect($draw->status)->toBe(DrawStatus::Cooldown->value);
Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:01', 'UTC')->addMinutes(16));
app(DrawTickService::class)->tick(now()->utc());
$draw->refresh();
expect($draw->status)->toBe(DrawStatus::Settling->value);
Carbon::setTestNow();
});
test('GET draw current returns open draw with seconds to close', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 15:00:00', 'UTC'));
$drawTime = now()->copy()->addHour();
$closeTime = $drawTime->copy()->subSeconds(30);
Draw::query()->create([
'draw_no' => '20260509-300',
'business_date' => '2026-05-09',
'sequence_no' => 300,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(5),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->getJson('/api/v1/draw/current')
->assertOk()
->assertJsonPath('data.draw_no', '20260509-300')
->assertJsonPath('data.status', DrawStatus::Open->value)
->assertJsonPath('data.seconds_to_close', 60 * 60 - 30)
->assertJsonPath('data.seconds_to_draw', 3600);
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');
$closeTime = $drawTime->copy()->subSeconds(30);
Draw::query()->create([
'draw_no' => '20260509-310',
'business_date' => '2026-05-09',
'sequence_no' => 310,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(5),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->getJson('/api/v1/draw/current')
->assertOk()
->assertJsonPath('data.draw_no', '20260509-310')
->assertJsonPath('data.status', DrawStatus::Closing->value)
->assertJsonPath('data.seconds_to_close', 0)
->assertJsonPath('data.seconds_to_draw', 20);
Carbon::setTestNow();
});
test('GET draw current exposes closed when row is open in DB but draw_time has passed', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 16:31:00', 'UTC'));
$drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');
$closeTime = $drawTime->copy()->subSeconds(30);
Draw::query()->create([
'draw_no' => '20260509-311',
'business_date' => '2026-05-09',
'sequence_no' => 311,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(5),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->getJson('/api/v1/draw/current')
->assertOk()
->assertJsonPath('data.draw_no', '20260509-311')
->assertJsonPath('data.status', DrawStatus::Closed->value)
->assertJsonPath('data.seconds_to_close', 0)
->assertJsonPath('data.seconds_to_draw', 0);
Carbon::setTestNow();
});
test('GET draw current includes result_items when cooldown', function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-09 16:10:00', 'UTC'));
$drawRow = Draw::query()->create([
'draw_no' => '20260509-400',
'business_date' => '2026-05-09',
'sequence_no' => 400,
'status' => DrawStatus::Cooldown->value,
'start_time' => now()->copy()->subHour(),
'close_time' => now()->copy()->subMinutes(30),
'draw_time' => now()->copy()->subMinutes(20),
'cooling_end_time' => now()->copy()->addMinutes(10),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
]);
$batch = DrawResultBatch::query()->create([
'draw_id' => $drawRow->id,
'result_version' => 1,
'source_type' => 'rng',
'rng_seed_hash' => hash('sha256', 'fixture'),
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
DrawResultItem::query()->create([
'draw_id' => $drawRow->id,
'result_batch_id' => $batch->id,
'prize_type' => 'first',
'prize_index' => 0,
'number_4d' => '1234',
'suffix_3d' => '234',
'suffix_2d' => '34',
'head_digit' => 1,
'tail_digit' => 4,
]);
$this->getJson('/api/v1/draw/current')
->assertOk()
->assertJsonPath('data.status', DrawStatus::Cooldown->value)
->assertJsonPath('data.result_items.0.number_4d', '1234');
Carbon::setTestNow();
});
test('lottery draw-tick command runs successfully', function (): void {
Carbon::setTestNow(Carbon::parse('2030-06-01 12:00:00', 'UTC'));
$this->artisan('lottery:draw-tick')->assertSuccessful();
Carbon::setTestNow();
});
test('lottery hall-countdown dispatches draw.countdown when using reverb connection', function (): void {
Event::fake([DrawCountdownBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$this->artisan('lottery:hall-countdown')->assertSuccessful();
Event::assertDispatched(DrawCountdownBroadcast::class);
});
test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void {
Event::fake([DrawStatusChangeBroadcast::class]);
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC'));
$drawTime = now()->copy()->addMinutes(30);
$closeTime = now()->copy()->subMinute();
Draw::query()->create([
'draw_no' => '20260509-099',
'business_date' => '2026-05-09',
'sequence_no' => 99,
'status' => DrawStatus::Open->value,
'start_time' => $closeTime->copy()->subMinutes(50),
'close_time' => $closeTime,
'draw_time' => $drawTime,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
app(DrawTickService::class)->tick(now()->utc());
Event::assertDispatched(DrawStatusChangeBroadcast::class);
Carbon::setTestNow();
});

View File

@@ -0,0 +1,134 @@
<?php
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\DrawResultItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function seedMinimalPublishedDraw(array $attrs, string $digit): Draw
{
$draw = Draw::query()->create($attrs);
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'rng_seed_hash' => hash('sha256', $digit),
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => 'first',
'prize_index' => 0,
'number_4d' => str_repeat($digit, 4),
'suffix_3d' => str_repeat($digit, 3),
'suffix_2d' => str_repeat($digit, 2),
'head_digit' => (int) $digit,
'tail_digit' => (int) $digit,
]);
return $draw->fresh();
}
test('draw results index returns published draws with PRD shaped results', function (): void {
$draw = seedMinimalPublishedDraw([
'draw_no' => '20260509-111',
'business_date' => '2026-05-09',
'sequence_no' => 111,
'status' => DrawStatus::Cooldown->value,
'start_time' => now()->subHour(),
'close_time' => now()->subMinutes(45),
'draw_time' => now()->subMinutes(30),
'cooling_end_time' => now()->addMinutes(10),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
], '8');
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
foreach (['second', 'third'] as $tier) {
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $tier,
'prize_index' => 0,
'number_4d' => $tier === 'second' ? '7777' : '6666',
'suffix_3d' => '777',
'suffix_2d' => '77',
'head_digit' => 7,
'tail_digit' => 7,
]);
}
$this->getJson('/api/v1/draw/results?per_page=5')
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.items.0.draw_no', '20260509-111')
->assertJsonPath('data.items.0.results.1st', '8888')
->assertJsonPath('data.items.0.results.2nd', '7777')
->assertJsonPath('data.items.0.results.3rd', '6666');
});
test('draw result show includes neighbor draw numbers', function (): void {
$t0 = now()->subHours(3);
seedMinimalPublishedDraw([
'draw_no' => '20260509-100',
'business_date' => '2026-05-09',
'sequence_no' => 100,
'status' => DrawStatus::Cooldown->value,
'start_time' => $t0,
'close_time' => $t0,
'draw_time' => $t0->copy()->addSecond(),
'cooling_end_time' => null,
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
], '1');
$t1 = now()->subHours(2);
seedMinimalPublishedDraw([
'draw_no' => '20260509-101',
'business_date' => '2026-05-09',
'sequence_no' => 101,
'status' => DrawStatus::Cooldown->value,
'start_time' => $t1,
'close_time' => $t1,
'draw_time' => $t1->copy()->addSecond(),
'cooling_end_time' => null,
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
], '2');
$t2 = now()->subHour();
seedMinimalPublishedDraw([
'draw_no' => '20260509-102',
'business_date' => '2026-05-09',
'sequence_no' => 102,
'status' => DrawStatus::Cooldown->value,
'start_time' => $t2,
'close_time' => $t2,
'draw_time' => $t2->copy()->addSecond(),
'cooling_end_time' => null,
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
], '3');
$this->getJson('/api/v1/draw/results/20260509-101')
->assertOk()
->assertJsonPath('data.previous_draw_no', '20260509-100')
->assertJsonPath('data.next_draw_no', '20260509-102');
});