From aeaf124096c187a4a23654271fd2cc35e5d94f22 Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 9 May 2026 17:40:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Laravel=20Reverb?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4=E6=96=B0=20.env.example?= =?UTF-8?q?=20=E6=96=87=E4=BB=B6=E4=BB=A5=E9=85=8D=E7=BD=AE=20WebSocket?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=BD=A9=E7=A5=A8=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0=20API=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E4=BB=A5=E6=94=AF=E6=8C=81=E6=9C=9F=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E7=BB=93=E6=9E=9C=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 +- README.md | 28 + .../Commands/LotteryDrawTickCommand.php | 32 + .../Commands/LotteryHallCountdownCommand.php | 20 + app/Events/DrawCountdownBroadcast.php | 43 + app/Events/DrawResultPublishedBroadcast.php | 43 + app/Events/DrawStatusChangeBroadcast.php | 43 + .../Admin/Draw/AdminDrawIndexController.php | 69 ++ .../AdminDrawResultBatchesIndexController.php | 59 + .../V1/Admin/Draw/AdminDrawShowController.php | 59 + .../Draw/DrawResultBatchPublishController.php | 64 ++ .../Api/V1/Draw/DrawCurrentController.php | 24 + .../Api/V1/Draw/DrawResultShowController.php | 59 + .../V1/Draw/DrawResultsIndexController.php | 62 + app/Lottery/DrawResultBatchStatus.php | 16 + app/Lottery/DrawResultSourceType.php | 10 + app/Lottery/DrawStatus.php | 39 + app/Models/Draw.php | 55 + app/Models/DrawResultBatch.php | 49 + app/Models/DrawResultItem.php | 43 + app/Services/Draw/DrawHallSnapshotBuilder.php | 182 +++ app/Services/Draw/DrawPlannerService.php | 168 +++ app/Services/Draw/DrawPrizeLayout.php | 27 + app/Services/Draw/DrawPublishService.php | 89 ++ app/Services/Draw/DrawResultViewService.php | 169 +++ app/Services/Draw/DrawRngRunner.php | 116 ++ app/Services/Draw/DrawTickService.php | 134 +++ .../Draw/LotteryHallRealtimeBroadcaster.php | 80 ++ bootstrap/app.php | 10 +- composer.json | 1 + composer.lock | 1004 ++++++++++++++++- config/broadcasting.php | 82 ++ config/lottery.php | 21 + config/reverb.php | 102 ++ ...000_migrate_draw_status_to_domain_dict.php | 22 + database/seeders/DatabaseSeeder.php | 3 +- database/seeders/DrawDemoSeeder.php | 139 +++ routes/api.php | 25 + routes/channels.php | 7 + tests/Feature/AdminDrawApiTest.php | 144 +++ tests/Feature/DrawPipelineTest.php | 398 +++++++ tests/Feature/DrawResultsApiTest.php | 134 +++ 42 files changed, 3886 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/LotteryDrawTickCommand.php create mode 100644 app/Console/Commands/LotteryHallCountdownCommand.php create mode 100644 app/Events/DrawCountdownBroadcast.php create mode 100644 app/Events/DrawResultPublishedBroadcast.php create mode 100644 app/Events/DrawStatusChangeBroadcast.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php create mode 100644 app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php create mode 100644 app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php create mode 100644 app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php create mode 100644 app/Lottery/DrawResultBatchStatus.php create mode 100644 app/Lottery/DrawResultSourceType.php create mode 100644 app/Lottery/DrawStatus.php create mode 100644 app/Models/Draw.php create mode 100644 app/Models/DrawResultBatch.php create mode 100644 app/Models/DrawResultItem.php create mode 100644 app/Services/Draw/DrawHallSnapshotBuilder.php create mode 100644 app/Services/Draw/DrawPlannerService.php create mode 100644 app/Services/Draw/DrawPrizeLayout.php create mode 100644 app/Services/Draw/DrawPublishService.php create mode 100644 app/Services/Draw/DrawResultViewService.php create mode 100644 app/Services/Draw/DrawRngRunner.php create mode 100644 app/Services/Draw/DrawTickService.php create mode 100644 app/Services/Draw/LotteryHallRealtimeBroadcaster.php create mode 100644 config/broadcasting.php create mode 100644 config/reverb.php create mode 100644 database/migrations/2026_05_09_120000_migrate_draw_status_to_domain_dict.php create mode 100644 database/seeders/DrawDemoSeeder.php create mode 100644 routes/channels.php create mode 100644 tests/Feature/AdminDrawApiTest.php create mode 100644 tests/Feature/DrawPipelineTest.php create mode 100644 tests/Feature/DrawResultsApiTest.php diff --git a/.env.example b/.env.example index d126ce0..7fb3127 100644 --- a/.env.example +++ b/.env.example @@ -94,8 +94,18 @@ SESSION_DOMAIN=null # 广播与文件(config/broadcasting.php、config/filesystems.php) # ============================================================================= -# 广播驱动:log / pusher / redis 等(未用实时广播可保持 log) -BROADCAST_CONNECTION=log +# 广播驱动:null / log / reverb(大厅 WebSocket 快照推荐) / pusher 等 +# 使用 reverb 时另开终端:php artisan reverb:start(与 php artisan serve 并行) +# 大厅 draw.countdown 每秒需跑调度:php artisan schedule:work(或等同),勿仅用每分钟 cron +BROADCAST_CONNECTION=reverb + +# Laravel Reverb(config/reverb.php);玩家端 Echo:NEXT_PUBLIC_REVERB_APP_KEY/HOST/PORT/SCHEME(与 REVERB_* 对齐) +REVERB_APP_ID= +REVERB_APP_KEY= +REVERB_APP_SECRET= +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http # 默认文件存储盘:local / s3 等 FILESYSTEM_DISK=local @@ -179,6 +189,9 @@ LOTTERY_SETTINGS_CACHE_TTL=60 # 开发绕过:Authorization: Bearer dev:{players.id};仅当 APP_ENV 为 local 或 testing 且为 true 时生效(PHPUnit 依赖 testing),生产务必 false LOTTERY_PLAYER_AUTH_DEV_BYPASS=false +# 未来期缓冲条数(draw_time>now 的期数,分钟 tick 会补足);测试可 6–12,生产可 48+ +LOTTERY_DRAW_BUFFER_AHEAD=8 + # 校验主站 JWT 的算法(与签发方一致) LOTTERY_JWT_ALGORITHM=HS256 # JWT 内表示站点编码的 claim 名 diff --git a/README.md b/README.md index 5ad1377..27239f6 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,34 @@ php artisan boost:install Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. +## 本地开发(可复制启动) + +在 **本仓库根目录**(即含 `artisan` 的 `lotterLaravel` 目录)开 **3 个终端**,每段整段复制即可。若已用 Herd / Valet / Sail / 自有 Web 服务器指到 `public`,可不跑「终端 1」。 + +**前置(首次)**:`cp .env.example .env`,`composer install`,`php artisan key:generate`,`php artisan migrate`(及你的库表/种子)。 + +**终端 1 — HTTP API** + +```bash +php artisan serve +``` + +**终端 2 — WebSocket(`.env` 里 `BROADCAST_CONNECTION=reverb` 时必开)** + +```bash +php artisan reverb:start +``` + +**终端 3 — 任务调度(`draw.countdown` 每秒 + `lottery:draw-tick` 每分钟)** + +```bash +php artisan schedule:work +``` + +> 仅用系统 cron 每分钟执行一次 `schedule:run` **无法覆盖「每秒」的 `lottery:hall-countdown`**,开发大厅实时倒计时时请用 `schedule:work`(或生产上等价常驻调度进程)。 + +只做 HTTP / 降级轮询、不测 WebSocket 时:**终端 2、3 可先不开**;要完整大厅 WS,则 **三项都开**。 + ## Contributing Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). diff --git a/app/Console/Commands/LotteryDrawTickCommand.php b/app/Console/Commands/LotteryDrawTickCommand.php new file mode 100644 index 0000000..218e95c --- /dev/null +++ b/app/Console/Commands/LotteryDrawTickCommand.php @@ -0,0 +1,32 @@ +tick(); + + $statusSum = array_sum($report['status_updates'] ?? []); + $this->info(sprintf( + 'Status rows updated: %d | RNG runs: %d | Planned draws created: %d', + $statusSum, + $report['rng_rung'], + $report['planned']['created'] ?? 0, + )); + + foreach ($report['rng_errors'] as $err) { + $this->warn($err); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/LotteryHallCountdownCommand.php b/app/Console/Commands/LotteryHallCountdownCommand.php new file mode 100644 index 0000000..8ee28c4 --- /dev/null +++ b/app/Console/Commands/LotteryHallCountdownCommand.php @@ -0,0 +1,20 @@ +countdownPulse(); + + return self::SUCCESS; + } +} diff --git a/app/Events/DrawCountdownBroadcast.php b/app/Events/DrawCountdownBroadcast.php new file mode 100644 index 0000000..e2e9205 --- /dev/null +++ b/app/Events/DrawCountdownBroadcast.php @@ -0,0 +1,43 @@ +|null $data 与 GET draw/current 的 data 相同 + */ + public function __construct( + public readonly ?array $data, + public readonly int $emittedAtMs, + ) {} + + /** @return array */ + public function broadcastOn(): array + { + return [new Channel('lottery-hall')]; + } + + public function broadcastAs(): string + { + return 'draw.countdown'; + } + + /** @return array{data: array|null, emitted_at_ms: int} */ + public function broadcastWith(): array + { + return [ + 'data' => $this->data, + 'emitted_at_ms' => $this->emittedAtMs, + ]; + } +} diff --git a/app/Events/DrawResultPublishedBroadcast.php b/app/Events/DrawResultPublishedBroadcast.php new file mode 100644 index 0000000..5dfd000 --- /dev/null +++ b/app/Events/DrawResultPublishedBroadcast.php @@ -0,0 +1,43 @@ +|null $data 与 GET draw/current 的 data 相同(含 result_items) + */ + public function __construct( + public readonly ?array $data, + public readonly int $emittedAtMs, + ) {} + + /** @return array */ + public function broadcastOn(): array + { + return [new Channel('lottery-hall')]; + } + + public function broadcastAs(): string + { + return 'result.published'; + } + + /** @return array{data: array|null, emitted_at_ms: int} */ + public function broadcastWith(): array + { + return [ + 'data' => $this->data, + 'emitted_at_ms' => $this->emittedAtMs, + ]; + } +} diff --git a/app/Events/DrawStatusChangeBroadcast.php b/app/Events/DrawStatusChangeBroadcast.php new file mode 100644 index 0000000..9e05c0d --- /dev/null +++ b/app/Events/DrawStatusChangeBroadcast.php @@ -0,0 +1,43 @@ +|null $data 与 GET draw/current 的 data 相同 + */ + public function __construct( + public readonly ?array $data, + public readonly int $emittedAtMs, + ) {} + + /** @return array */ + public function broadcastOn(): array + { + return [new Channel('lottery-hall')]; + } + + public function broadcastAs(): string + { + return 'draw.status_change'; + } + + /** @return array{data: array|null, emitted_at_ms: int} */ + public function broadcastWith(): array + { + return [ + 'data' => $this->data, + 'emitted_at_ms' => $this->emittedAtMs, + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php new file mode 100644 index 0000000..edc58c9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php @@ -0,0 +1,69 @@ +integer('per_page', 25), 1), 100); + $drawNo = trim((string) $request->query('draw_no', '')); + $status = trim((string) $request->query('status', '')); + + $q = Draw::query()->orderByDesc('draw_time')->orderByDesc('id'); + + if ($drawNo !== '') { + $q->where('draw_no', 'like', '%'.$drawNo.'%'); + } + + if ($status !== '') { + $q->where('status', $status); + } + + /** @var \Illuminate\Contracts\Pagination\LengthAwarePaginator $paginator */ + $paginator = $q->paginate($perPage); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (Draw $row) => $this->row($row))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(Draw $draw): array + { + return [ + 'id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'business_date' => $draw->business_date instanceof Carbon + ? $draw->business_date->format('Y-m-d') + : (string) $draw->business_date, + 'sequence_no' => (int) $draw->sequence_no, + 'status' => $draw->status, + 'start_time' => $draw->start_time?->toIso8601String(), + 'close_time' => $draw->close_time?->toIso8601String(), + 'draw_time' => $draw->draw_time?->toIso8601String(), + 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), + 'result_source' => $draw->result_source, + 'current_result_version' => (int) $draw->current_result_version, + 'settle_version' => (int) $draw->settle_version, + 'is_reopened' => (bool) $draw->is_reopened, + 'updated_at' => $draw->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php new file mode 100644 index 0000000..2a79142 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php @@ -0,0 +1,59 @@ +resultBatches() + ->with(['items' => function ($q): void { + $q->orderBy('prize_type')->orderBy('prize_index'); + }]) + ->orderByDesc('result_version') + ->get(); + + return ApiResponse::success([ + 'draw_id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'draw_status' => $draw->status, + 'batches' => $batches->map(fn (DrawResultBatch $b) => $this->serializeBatch($b))->all(), + ]); + } + + /** @return array */ + private function serializeBatch(DrawResultBatch $batch): array + { + return [ + 'id' => (int) $batch->id, + 'result_version' => (int) $batch->result_version, + 'source_type' => $batch->source_type, + 'rng_seed_hash' => $batch->rng_seed_hash, + 'status' => $batch->status, + 'created_by' => $batch->created_by, + 'confirmed_by' => $batch->confirmed_by, + 'confirmed_at' => $batch->confirmed_at?->toIso8601String(), + 'created_at' => $batch->created_at?->toIso8601String(), + 'updated_at' => $batch->updated_at?->toIso8601String(), + 'items' => $batch->items->map(fn (DrawResultItem $item) => [ + 'prize_type' => $item->prize_type, + 'prize_index' => (int) $item->prize_index, + 'number_4d' => $item->number_4d, + 'suffix_3d' => $item->suffix_3d, + 'suffix_2d' => $item->suffix_2d, + 'head_digit' => $item->head_digit, + 'tail_digit' => $item->tail_digit, + ])->values()->all(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php new file mode 100644 index 0000000..d736dc6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php @@ -0,0 +1,59 @@ +utc(); + $batchCounts = [ + 'total' => $draw->resultBatches()->count(), + 'pending_review' => $draw->resultBatches() + ->where('status', DrawResultBatchStatus::PendingReview->value) + ->count(), + 'published' => $draw->resultBatches() + ->where('status', DrawResultBatchStatus::Published->value) + ->count(), + ]; + + return ApiResponse::success([ + 'id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'business_date' => $draw->business_date instanceof Carbon + ? $draw->business_date->format('Y-m-d') + : (string) $draw->business_date, + 'sequence_no' => (int) $draw->sequence_no, + /** 数据库当期状态(权威) */ + 'status' => $draw->status, + /** 与玩家大厅 snapshot 对齐的展示态(未跑 tick 时可能与 status 不一致) */ + 'hall_preview_status' => $this->hallPreview->effectiveHallDisplayStatus($draw, $nowUtc), + 'start_time' => $draw->start_time?->toIso8601String(), + 'close_time' => $draw->close_time?->toIso8601String(), + 'draw_time' => $draw->draw_time?->toIso8601String(), + 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), + 'result_source' => $draw->result_source, + 'current_result_version' => (int) $draw->current_result_version, + 'settle_version' => (int) $draw->settle_version, + 'is_reopened' => (bool) $draw->is_reopened, + 'created_at' => $draw->created_at?->toIso8601String(), + 'updated_at' => $draw->updated_at?->toIso8601String(), + 'result_batch_counts' => $batchCounts, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php new file mode 100644 index 0000000..bfbda26 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php @@ -0,0 +1,64 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + if ((int) $batch->draw_id !== (int) $draw->id) { + return ApiResponse::error( + trans('api.not_found', [], $request->lotteryLocale()), + ErrorCode::NotFound->value, + null, + 404, + ); + } + + try { + $this->publishService->publishManualBatch($batch, $admin); + } catch (\RuntimeException) { + return ApiResponse::error( + trans('api.client_error', [], $request->lotteryLocale()), + ErrorCode::ClientHttpError->value, + null, + 409, + ); + } + + $draw->refresh(); + + return ApiResponse::success([ + 'draw_no' => $draw->draw_no, + 'status' => $draw->status, + 'result_version' => (int) $draw->current_result_version, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php b/app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php new file mode 100644 index 0000000..8f81074 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php @@ -0,0 +1,24 @@ +snapshot->build()); + } +} diff --git a/app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php b/app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php new file mode 100644 index 0000000..c7aa058 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php @@ -0,0 +1,59 @@ +]切换)。 + */ +class DrawResultShowController extends Controller +{ + public function __construct( + private readonly DrawResultViewService $viewer, + ) {} + + public function __invoke(Request $request, string $draw_no): JsonResponse + { + $draw_no = trim($draw_no); + + $draw = Draw::query()->where('draw_no', $draw_no)->first(); + if ($draw === null) { + return ApiResponse::error( + trans('api.not_found', [], $request->lotteryLocale()), + ErrorCode::NotFound->value, + null, + 404, + ); + } + + if (! in_array($draw->status, DrawResultViewService::publishedDrawStatuses(), true)) { + return ApiResponse::error( + trans('api.not_found', [], $request->lotteryLocale()), + ErrorCode::NotFound->value, + null, + 404, + ); + } + + $payload = $this->viewer->summarizeDraw($draw); + if ($payload === null) { + return ApiResponse::error( + trans('api.not_found', [], $request->lotteryLocale()), + ErrorCode::NotFound->value, + null, + 404, + ); + } + + $payload = [...$payload, ...$this->viewer->neighborsIsoTime($draw)]; + + return ApiResponse::success($payload); + } +} diff --git a/app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php b/app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php new file mode 100644 index 0000000..261abd4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php @@ -0,0 +1,62 @@ +query('size', $request->query('per_page', 15)))); + $page = max(1, (int) $request->query('page', 1)); + /** @var string|null $bizDate query `business_date` 或旧的 `date` */ + $bizDate = $request->query('business_date') ?? $request->query('date'); + + $query = Draw::query() + ->whereIn('status', DrawResultViewService::publishedDrawStatuses()) + ->where('current_result_version', '>', 0) + ->whereNotNull('draw_time') + ->whereExists(function ($sub): void { + $sub->selectRaw('1') + ->from((new DrawResultBatch)->getTable()) + ->whereColumn('draw_id', 'draws.id') + ->whereColumn('result_version', 'draws.current_result_version') + ->where('status', DrawResultBatchStatus::Published->value); + }); + + if (is_string($bizDate) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $bizDate)) { + $query->whereDate('business_date', $bizDate); + } + + /** @var LengthAwarePaginator $paginator */ + $paginator = $query + ->orderByDesc('draw_time') + ->paginate(perPage: $perPage, columns: ['*'], pageName: 'page', page: $page); + + $decorated = $this->viewer->decoratePaginator($paginator); + + return ApiResponse::success([ + 'items' => $decorated->items(), + 'total' => $decorated->total(), + 'page' => $decorated->currentPage(), + 'per_page' => $decorated->perPage(), + 'last_page' => $decorated->lastPage(), + ]); + } +} diff --git a/app/Lottery/DrawResultBatchStatus.php b/app/Lottery/DrawResultBatchStatus.php new file mode 100644 index 0000000..3fc588a --- /dev/null +++ b/app/Lottery/DrawResultBatchStatus.php @@ -0,0 +1,16 @@ + 'date', + 'start_time' => 'datetime', + 'close_time' => 'datetime', + 'draw_time' => 'datetime', + 'cooling_end_time' => 'datetime', + 'current_result_version' => 'integer', + 'settle_version' => 'integer', + 'is_reopened' => 'boolean', + ]; + } + + public function statusEnum(): ?DrawStatus + { + return DrawStatus::tryFrom((string) $this->status); + } + + public function resultBatches(): HasMany + { + return $this->hasMany(DrawResultBatch::class); + } + + public function resultItems(): HasMany + { + return $this->hasMany(DrawResultItem::class); + } +} diff --git a/app/Models/DrawResultBatch.php b/app/Models/DrawResultBatch.php new file mode 100644 index 0000000..b334880 --- /dev/null +++ b/app/Models/DrawResultBatch.php @@ -0,0 +1,49 @@ + 'integer', + 'confirmed_at' => 'datetime', + ]; + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function items(): HasMany + { + return $this->hasMany(DrawResultItem::class, 'result_batch_id'); + } + + public function statusEnum(): ?DrawResultBatchStatus + { + return DrawResultBatchStatus::tryFrom((string) $this->status); + } +} diff --git a/app/Models/DrawResultItem.php b/app/Models/DrawResultItem.php new file mode 100644 index 0000000..4f060a1 --- /dev/null +++ b/app/Models/DrawResultItem.php @@ -0,0 +1,43 @@ + 'integer', + 'head_digit' => 'integer', + 'tail_digit' => 'integer', + ]; + } + + public function draw(): BelongsTo + { + return $this->belongsTo(Draw::class); + } + + public function batch(): BelongsTo + { + return $this->belongsTo(DrawResultBatch::class, 'result_batch_id'); + } +} diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php new file mode 100644 index 0000000..40c3a51 --- /dev/null +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -0,0 +1,182 @@ +|null + */ +final class DrawHallSnapshotBuilder +{ + /** + * Tick 未及时跑时,DB 仍为 `open` 但已到封盘时刻;对外快照与界面应对齐真实可下注态(见 DrawTickService::openToClosingOrClosed)。 + * + * 后台「当前大厅可见状态」预览可共用本方法。 + */ + public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string + { + $db = (string) $target->status; + if ($db !== DrawStatus::Open->value) { + return $db; + } + + $closeUtc = $target->close_time; + if (! $closeUtc instanceof Carbon || $closeUtc > $nowUtc) { + return $db; + } + + $drawUtc = $target->draw_time; + if ($drawUtc instanceof Carbon && $drawUtc <= $nowUtc) { + return DrawStatus::Closed->value; + } + + return DrawStatus::Closing->value; + } + + private function showsPublishedResults(string $drawStatus): bool + { + return in_array($drawStatus, [ + DrawStatus::Cooldown->value, + DrawStatus::Settling->value, + DrawStatus::Settled->value, + ], true); + } + + /** 与 {@see build()} 使用同一套「大厅指向的当期行」 */ + public function resolveHallTarget(?Carbon $nowUtc = null): ?Draw + { + $nowUtc = ($nowUtc ?? Carbon::now())->utc(); + + $bettingOpen = Draw::query() + ->where('status', DrawStatus::Open->value) + ->where(function ($q) use ($nowUtc): void { + $q->whereNull('close_time') + ->orWhere('close_time', '>', $nowUtc); + }) + ->orderBy('draw_time') + ->first(); + + $chronological = Draw::query() + ->whereNotIn('status', [ + DrawStatus::Settled->value, + DrawStatus::Cancelled->value, + ]) + ->orderBy('draw_time') + ->first(); + + return $bettingOpen ?? $chronological; + } + + /** + * {@see DrawTickService} 发 `draw.status_change` 用:按 **数据库** `draw_no`+`status`,不用展示态规范化。 + * + * @return array{draw_no: string, status: string}|null + */ + public function hallTargetFingerprint(?Carbon $nowUtc = null): ?array + { + $target = $this->resolveHallTarget($nowUtc); + if ($target === null) { + return null; + } + + return [ + 'draw_no' => (string) $target->draw_no, + 'status' => (string) $target->status, + ]; + } + + /** + * @return array|null + */ + public function build(?Carbon $nowUtc = null): ?array + { + $nowUtc = ($nowUtc ?? Carbon::now())->utc(); + + $target = $this->resolveHallTarget($nowUtc); + + if ($target === null) { + return null; + } + + $closeUtc = $target->close_time; + $secsToClose = ($closeUtc !== null && $closeUtc > $nowUtc) + ? max(0, (int) $closeUtc->getTimestamp() - (int) $nowUtc->getTimestamp()) + : 0; + + $secsToDraw = ($target->draw_time !== null && $target->draw_time > $nowUtc) + ? max(0, (int) $target->draw_time->getTimestamp() - (int) $nowUtc->getTimestamp()) + : 0; + + $coolingRemain = null; + if ( + $target->cooling_end_time instanceof Carbon + && $target->cooling_end_time > $nowUtc + ) { + $coolingRemain = max( + 0, + (int) $target->cooling_end_time->getTimestamp() - (int) $nowUtc->getTimestamp(), + ); + } + + $effectiveStatus = $this->effectiveHallDisplayStatus($target, $nowUtc); + + $payload = [ + 'draw_no' => $target->draw_no, + 'business_date' => $target->business_date instanceof Carbon + ? $target->business_date->format('Y-m-d') + : (string) $target->business_date, + 'sequence_no' => (int) $target->sequence_no, + 'status' => $effectiveStatus, + 'start_time' => $target->start_time?->toIso8601String(), + 'close_time' => $target->close_time?->toIso8601String(), + 'draw_time' => $target->draw_time?->toIso8601String(), + 'seconds_to_close' => $secsToClose, + 'seconds_to_draw' => $secsToDraw, + 'cooling_end_time' => $target->cooling_end_time?->toIso8601String(), + 'seconds_remaining_in_cooldown' => $coolingRemain, + ]; + + if ($this->showsPublishedResults((string) $target->status)) { + $batchId = DrawResultBatch::query() + ->where('draw_id', $target->id) + ->where('result_version', (int) $target->current_result_version) + ->where('status', DrawResultBatchStatus::Published->value) + ->value('id'); + + if ($batchId !== null) { + $payload['result_items'] = DrawResultItem::query() + ->where('result_batch_id', $batchId) + ->orderBy('prize_type') + ->orderBy('prize_index') + ->get([ + 'prize_type', 'prize_index', + 'number_4d', 'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit', + ]) + ->map(fn ($row) => [ + 'prize_type' => $row->prize_type, + 'prize_index' => (int) $row->prize_index, + 'number_4d' => $row->number_4d, + 'suffix_3d' => $row->suffix_3d, + 'suffix_2d' => $row->suffix_2d, + 'head_digit' => $row->head_digit, + 'tail_digit' => $row->tail_digit, + ]) + ->values() + ->all(); + } + + $payload['result_version'] = (int) $target->current_result_version; + $payload['result_source'] = $target->result_source; + } + + return $payload; + } +} diff --git a/app/Services/Draw/DrawPlannerService.php b/app/Services/Draw/DrawPlannerService.php new file mode 100644 index 0000000..22dc922 --- /dev/null +++ b/app/Services/Draw/DrawPlannerService.php @@ -0,0 +1,168 @@ +utc(); + $tz = (string) config('lottery.draw.timezone', 'UTC'); + $interval = (int) config('lottery.draw.interval_minutes', 5); + $buffer = (int) config('lottery.draw.buffer_draws_ahead', 8); + $maxSeq = intdiv(24 * 60, $interval); + + $upcoming = Draw::query() + ->where('draw_time', '>', $nowUtc) + ->where('status', '!=', DrawStatus::Cancelled->value) + ->count(); + + $created = 0; + $guard = 0; + while ($upcoming < $buffer && $guard < 10_000) { + $guard++; + $nowLocal = $nowUtc->copy()->timezone($tz); + $last = Draw::query() + ->orderByDesc('business_date') + ->orderByDesc('sequence_no') + ->first(); + + $row = $last === null + ? $this->firstSchedule($tz, $interval, $maxSeq, $nowLocal) + : $this->scheduleAfter($last, $tz, $interval, $maxSeq, $nowLocal); + + try { + DB::transaction(function () use ($row, $nowUtc, &$created): void { + Draw::query()->create($this->timelinePayload($row, $nowUtc)); + $created++; + }); + $upcoming++; + } catch (QueryException $e) { + if (($e->errorInfo[1] ?? null) === 19 || str_contains($e->getMessage(), 'unique')) { + /** 并发或重试:下一循环用新的 last 行 */ + continue; + } + throw $e; + } + } + + return [ + 'created' => $created, + 'buffer_target' => $buffer, + 'upcoming' => $upcoming, + ]; + } + + /** + * @return array{business_date: string, sequence_no: int, draw_local: Carbon} + */ + private function firstSchedule(string $tz, int $intervalMinutes, int $maxSeqPerDay, Carbon $nowLocal): array + { + $day = $nowLocal->copy()->startOfDay(); + $seq = 1; + $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); + while ($drawLocal <= $nowLocal && $seq <= $maxSeqPerDay) { + $seq++; + $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); + } + if ($seq > $maxSeqPerDay) { + $day = $day->addDay(); + $seq = 1; + $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); + while ($drawLocal <= $nowLocal && $seq <= $maxSeqPerDay) { + $seq++; + $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); + } + } + + return [ + 'business_date' => $day->format('Y-m-d'), + 'sequence_no' => $seq, + 'draw_local' => $drawLocal->copy()->timezone($tz), + ]; + } + + /** + * @return array{business_date: string, sequence_no: int, draw_local: Carbon} + */ + private function scheduleAfter(Draw $last, string $tz, int $intervalMinutes, int $maxSeqPerDay, Carbon $nowLocal): array + { + $day = Carbon::parse((string) $last->business_date, $tz)->startOfDay(); + $seq = (int) $last->sequence_no + 1; + if ($seq > $maxSeqPerDay) { + $day = $day->addDay(); + $seq = 1; + } + + $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); + while ($drawLocal <= $nowLocal) { + $seq++; + if ($seq > $maxSeqPerDay) { + $day = $day->addDay(); + $seq = 1; + } + $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); + } + + return [ + 'business_date' => $day->format('Y-m-d'), + 'sequence_no' => $seq, + 'draw_local' => $drawLocal->copy()->timezone($tz), + ]; + } + + /** + * @param array{business_date: string, sequence_no: int, draw_local: Carbon} $row + * @return array + */ + private function timelinePayload(array $row, Carbon $nowUtc): array + { + $closeBefore = (int) config('lottery.draw.close_before_draw_seconds', 30); + $bettingWindow = (int) config('lottery.draw.betting_window_seconds', 270); + + $drawLocal = $row['draw_local']->copy(); + + $closeLocal = $drawLocal->copy()->subSeconds($closeBefore); + $startLocal = $closeLocal->copy()->subSeconds($bettingWindow); + + $startUtc = $startLocal->copy()->timezone('UTC'); + $closeUtc = $closeLocal->copy()->timezone('UTC'); + $drawUtc = $drawLocal->copy()->timezone('UTC'); + + if ($nowUtc < $startUtc) { + $status = DrawStatus::Pending->value; + } elseif ($nowUtc < $closeUtc) { + $status = DrawStatus::Open->value; + } elseif ($nowUtc < $drawUtc) { + $status = DrawStatus::Closing->value; + } else { + $status = DrawStatus::Closed->value; + } + + return [ + 'draw_no' => str_replace('-', '', $row['business_date']).'-'. + str_pad((string) $row['sequence_no'], 3, '0', STR_PAD_LEFT), + 'business_date' => $row['business_date'], + 'sequence_no' => $row['sequence_no'], + 'status' => $status, + 'start_time' => $startLocal->copy()->timezone('UTC'), + 'close_time' => $closeLocal->copy()->timezone('UTC'), + 'draw_time' => $drawLocal->copy()->timezone('UTC'), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]; + } +} diff --git a/app/Services/Draw/DrawPrizeLayout.php b/app/Services/Draw/DrawPrizeLayout.php new file mode 100644 index 0000000..dec8c1c --- /dev/null +++ b/app/Services/Draw/DrawPrizeLayout.php @@ -0,0 +1,27 @@ + + */ +final class DrawPrizeLayout +{ + public static function slots(): array + { + $slots = []; + foreach (['first', 'second', 'third'] as $tier) { + $slots[] = ['prize_type' => $tier, 'prize_index' => 0]; + } + for ($i = 0; $i < 10; $i++) { + $slots[] = ['prize_type' => 'starter', 'prize_index' => $i]; + } + for ($i = 0; $i < 10; $i++) { + $slots[] = ['prize_type' => 'consolation', 'prize_index' => $i]; + } + + return $slots; + } +} diff --git a/app/Services/Draw/DrawPublishService.php b/app/Services/Draw/DrawPublishService.php new file mode 100644 index 0000000..afa022e --- /dev/null +++ b/app/Services/Draw/DrawPublishService.php @@ -0,0 +1,89 @@ +whereKey($batch->id)->lockForUpdate()->firstOrFail(); + if ($lockedBatch->status !== DrawResultBatchStatus::PendingReview->value) { + throw new \RuntimeException('batch_not_pending_review'); + } + + /** @var Draw $draw */ + $draw = Draw::query()->whereKey($lockedBatch->draw_id)->lockForUpdate()->firstOrFail(); + $lockedBatch->forceFill([ + 'status' => DrawResultBatchStatus::Published->value, + 'confirmed_by' => $admin->id, + 'confirmed_at' => now(), + ])->save(); + + return $this->applyPublishedToDraw($draw, $lockedBatch); + }); + + $data = $this->snapshot->build(); + $this->hallRealtime->notifyResultPublished($data); + $this->hallRealtime->notifyStatusChange($data); + + return $draw; + } + + /** RNG 自动生成且无需审核时在同一事务调用 */ + public function markPublishedInTransaction(Draw $draw, DrawResultBatch $batch): Draw + { + $batch->forceFill([ + 'status' => DrawResultBatchStatus::Published->value, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ])->save(); + + $draw = $this->applyPublishedToDraw($draw, $batch); + + DB::afterCommit(function (): void { + $data = app(DrawHallSnapshotBuilder::class)->build(); + app(LotteryHallRealtimeBroadcaster::class)->notifyResultPublished($data); + }); + + return $draw; + } + + private function applyPublishedToDraw(Draw $draw, DrawResultBatch $batch): Draw + { + $cooldownMinutes = (int) config('lottery.draw.cooldown_minutes', 15); + if ($cooldownMinutes > 0) { + $draw->forceFill([ + 'status' => DrawStatus::Cooldown->value, + 'current_result_version' => (int) $batch->result_version, + 'result_source' => $batch->source_type, + 'cooling_end_time' => now()->addMinutes($cooldownMinutes), + ])->save(); + } else { + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => (int) $batch->result_version, + 'result_source' => $batch->source_type, + 'cooling_end_time' => null, + ])->save(); + } + + return $draw->refresh(); + } +} diff --git a/app/Services/Draw/DrawResultViewService.php b/app/Services/Draw/DrawResultViewService.php new file mode 100644 index 0000000..aa10c46 --- /dev/null +++ b/app/Services/Draw/DrawResultViewService.php @@ -0,0 +1,169 @@ +, + * consolation: array + * } + */ + public function numbersFromItems(Collection $items): array + { + $byType = [ + 'first' => [], + 'second' => [], + 'third' => [], + 'starter' => [], + 'consolation' => [], + ]; + + foreach ($items->sortBy(['prize_type', 'prize_index']) as $row) { + /** @var DrawResultItem $row */ + $t = (string) $row->prize_type; + if (! isset($byType[$t])) { + continue; + } + $byType[$t][] = (string) $row->number_4d; + } + + return [ + '1st' => $byType['first'][0] ?? '', + '2nd' => $byType['second'][0] ?? '', + '3rd' => $byType['third'][0] ?? '', + 'starter' => array_values($byType['starter']), + 'consolation' => array_values($byType['consolation']), + ]; + } + + /** + * 返回 null 若该期尚未有可展示的开奖采纳版本。 + * + * @return array|null + */ + public function summarizeDraw(Draw $draw): ?array + { + $version = (int) $draw->current_result_version; + if ($version < 1) { + return null; + } + + $batch = DrawResultBatch::query() + ->where('draw_id', $draw->id) + ->where('result_version', $version) + ->where('status', DrawResultBatchStatus::Published->value) + ->first(); + + if ($batch === null) { + return null; + } + + $items = DrawResultItem::query() + ->where('result_batch_id', $batch->id) + ->orderBy('prize_type') + ->orderBy('prize_index') + ->get([ + 'prize_type', 'prize_index', 'number_4d', + 'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit', + ]); + + if ($items->isEmpty()) { + return null; + } + + $numbers = $this->numbersFromItems($items); + + return [ + 'draw_id' => $draw->draw_no, + 'draw_no' => $draw->draw_no, + 'business_date' => $draw->business_date?->format('Y-m-d') ?? (string) $draw->business_date, + 'draw_time' => $draw->draw_time?->format('Y-m-d H:i:s'), + 'draw_time_iso' => $draw->draw_time?->toIso8601String(), + 'result_version' => $version, + 'result_source' => $draw->result_source, + 'results' => $numbers, + 'result_items' => $items->map(fn (DrawResultItem $r) => [ + 'prize_type' => $r->prize_type, + 'prize_index' => (int) $r->prize_index, + 'number_4d' => $r->number_4d, + 'suffix_3d' => $r->suffix_3d, + 'suffix_2d' => $r->suffix_2d, + 'head_digit' => $r->head_digit !== null ? (int) $r->head_digit : null, + 'tail_digit' => $r->tail_digit !== null ? (int) $r->tail_digit : null, + ])->values()->all(), + ]; + } + + /** + * @param LengthAwarePaginator $paginator + */ + public function decoratePaginator(LengthAwarePaginator $paginator): LengthAwarePaginator + { + $collection = $paginator->getCollection()->map(function (Draw $draw): ?array { + return $this->summarizeDraw($draw); + })->filter(); + + $paginator->setCollection($collection->values()); + + return $paginator; + } + + /** 已发布开奖结果的可查询状态(对外展示往期)。 */ + public static function publishedDrawStatuses(): array + { + return [ + DrawStatus::Cooldown->value, + DrawStatus::Settling->value, + DrawStatus::Settled->value, + ]; + } + + public function neighborsIsoTime(Draw $draw): array + { + $statuses = self::publishedDrawStatuses(); + $t = $draw->draw_time; + $prevNo = null; + $nextNo = null; + + if ($t !== null) { + $prevNo = Draw::query() + ->whereIn('status', $statuses) + ->where('current_result_version', '>', 0) + ->whereNotNull('draw_time') + ->where('draw_time', '<', $t) + ->orderByDesc('draw_time') + ->value('draw_no'); + + $nextNo = Draw::query() + ->whereIn('status', $statuses) + ->where('current_result_version', '>', 0) + ->whereNotNull('draw_time') + ->where('draw_time', '>', $t) + ->orderBy('draw_time') + ->value('draw_no'); + } + + return [ + 'previous_draw_no' => $prevNo, + 'next_draw_no' => $nextNo, + ]; + } +} diff --git a/app/Services/Draw/DrawRngRunner.php b/app/Services/Draw/DrawRngRunner.php new file mode 100644 index 0000000..234ad72 --- /dev/null +++ b/app/Services/Draw/DrawRngRunner.php @@ -0,0 +1,116 @@ +forceFill([ + 'status' => DrawStatus::Drawing->value, + ])->save(); + + $manualReview = (bool) config('lottery.draw.require_manual_review', false); + $seedMaterial = bin2hex(random_bytes(32)); + $rngSeedHash = hash('sha256', $seedMaterial); + + $nextVersion = max(1, (int) $draw->current_result_version + 1); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => $nextVersion, + 'source_type' => DrawResultSourceType::Rng->value, + 'rng_seed_hash' => $rngSeedHash, + 'raw_seed_encrypted' => null, + 'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => $manualReview ? null : now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $num = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); + $suffix3 = substr($num, -3); + $suffix2 = substr($num, -2); + + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => $suffix3, + 'suffix_2d' => $suffix2, + 'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null, + 'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null, + ]); + } + + if ($manualReview) { + $draw->forceFill([ + 'status' => DrawStatus::Review->value, + 'result_source' => DrawResultSourceType::Rng->value, + ])->save(); + } else { + $this->publisher->markPublishedInTransaction($draw->fresh(), $batch->fresh()); + } + + return $batch->fresh(); + } + + /** + * @return array{rung: int, errors: array} + */ + public function runDue(?Carbon $now = null): array + { + $nowUtc = ($now ?? Carbon::now())->utc(); + $rung = 0; + $errors = []; + + $ids = Draw::query() + ->where('status', DrawStatus::Closed->value) + ->whereNotNull('draw_time') + ->where('draw_time', '<=', $nowUtc) + ->whereDoesntHave('resultBatches') + ->orderBy('draw_time') + ->pluck('id'); + + foreach ($ids as $drawId) { + try { + DB::transaction(function () use ($drawId, &$rung): void { + /** @var Draw|null $locked */ + $locked = Draw::query()->whereKey($drawId)->lockForUpdate()->first(); + if ($locked === null || $locked->status !== DrawStatus::Closed->value) { + return; + } + if ($locked->resultBatches()->exists()) { + return; + } + $this->executeLocked($locked); + $rung++; + }); + } catch (\Throwable $e) { + $errors[] = (string) $drawId.': '.$e->getMessage(); + } + } + + return ['rung' => $rung, 'errors' => $errors]; + } +} diff --git a/app/Services/Draw/DrawTickService.php b/app/Services/Draw/DrawTickService.php new file mode 100644 index 0000000..48ac046 --- /dev/null +++ b/app/Services/Draw/DrawTickService.php @@ -0,0 +1,134 @@ +, + * rng_rung: int, + * rng_errors: array, + * planned: array + * } + */ + public function tick(?Carbon $now = null): array + { + $nowUtc = ($now ?? Carbon::now())->utc(); + + $hallFpBefore = $this->hallSnapshot->hallTargetFingerprint($nowUtc); + + $statusUpdates = [ + 'pending_to_open_or_later' => $this->promoteStalePendingRows($nowUtc), + 'open_to_closing_or_closed' => $this->openToClosingOrClosed($nowUtc), + 'closing_to_closed' => $this->closingToClosed($nowUtc), + 'cooldown_to_settling' => $this->cooldownToSettling($nowUtc), + ]; + + $rngOutcome = $this->rng->runDue($nowUtc); + $planned = $this->planner->ensureBuffer($nowUtc); + + $report = [ + 'status_updates' => $statusUpdates, + 'rng_rung' => $rngOutcome['rung'], + 'rng_errors' => $rngOutcome['errors'], + 'planned' => $planned, + ]; + + $snapshotAfter = $this->hallSnapshot->build($nowUtc); + $hallFpAfter = $this->hallSnapshot->hallTargetFingerprint($nowUtc); + + $this->hallRealtime->notifyStatusChangeIfHallDbChanged($hallFpBefore, $hallFpAfter, $snapshotAfter); + + return $report; + } + + /** 补偿迟到的调度:pending 可依当前时刻落到 open / closing / closed。 */ + private function promoteStalePendingRows(Carbon $nowUtc): int + { + $toClosed = Draw::query() + ->where('status', DrawStatus::Pending->value) + ->whereNotNull('draw_time') + ->where('draw_time', '<=', $nowUtc) + ->update(['status' => DrawStatus::Closed->value]); + + $toClosing = Draw::query() + ->where('status', DrawStatus::Pending->value) + ->whereNotNull('close_time') + ->whereNotNull('draw_time') + ->where('close_time', '<=', $nowUtc) + ->where('draw_time', '>', $nowUtc) + ->update(['status' => DrawStatus::Closing->value]); + + $toOpen = Draw::query() + ->where('status', DrawStatus::Pending->value) + ->whereNotNull('start_time') + ->where('start_time', '<=', $nowUtc) + ->where(function ($q) use ($nowUtc): void { + $q->whereNull('close_time') + ->orWhere('close_time', '>', $nowUtc); + }) + ->update(['status' => DrawStatus::Open->value]); + + return (int) $toClosed + (int) $toClosing + (int) $toOpen; + } + + /** 先处理「已封盘且已越过开奖时刻」直达 closed,再走正常封盘中。 */ + private function openToClosingOrClosed(Carbon $nowUtc): int + { + $toClosed = Draw::query() + ->where('status', DrawStatus::Open->value) + ->whereNotNull('close_time') + ->where('close_time', '<=', $nowUtc) + ->whereNotNull('draw_time') + ->where('draw_time', '<=', $nowUtc) + ->update(['status' => DrawStatus::Closed->value]); + + $toClosing = Draw::query() + ->where('status', DrawStatus::Open->value) + ->whereNotNull('close_time') + ->where('close_time', '<=', $nowUtc) + ->where(function ($q) use ($nowUtc): void { + $q->whereNull('draw_time') + ->orWhere('draw_time', '>', $nowUtc); + }) + ->update(['status' => DrawStatus::Closing->value]); + + return (int) $toClosed + (int) $toClosing; + } + + private function closingToClosed(Carbon $nowUtc): int + { + return Draw::query() + ->where('status', DrawStatus::Closing->value) + ->whereNotNull('draw_time') + ->where('draw_time', '<=', $nowUtc) + ->update(['status' => DrawStatus::Closed->value]); + } + + /** 冷静期结束 → settling(结算/派彩由后续阶段补齐)。 */ + private function cooldownToSettling(Carbon $nowUtc): int + { + return Draw::query() + ->where('status', DrawStatus::Cooldown->value) + ->whereNotNull('cooling_end_time') + ->where('cooling_end_time', '<=', $nowUtc) + ->update(['status' => DrawStatus::Settling->value]); + } +} diff --git a/app/Services/Draw/LotteryHallRealtimeBroadcaster.php b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php new file mode 100644 index 0000000..1922ba9 --- /dev/null +++ b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php @@ -0,0 +1,80 @@ +driverSupportsRealtime()) { + return; + } + + $data = $this->snapshot->build(); + $ms = (int) floor(microtime(true) * 1000); + + broadcast(new DrawCountdownBroadcast($data, $ms)); + } + + /** + * Tick 首尾对比:**数据库**当期指纹({@see DrawHallSnapshotBuilder::hallTargetFingerprint})变了再发, + * 载荷仍为 {@see DrawHallSnapshotBuilder::build()}(含未到 tick 时对 `open` 的展示规范化)。 + */ + public function notifyStatusChangeIfHallDbChanged(?array $fpBefore, ?array $fpAfter, ?array $snapshotPayload): void + { + if (! $this->driverSupportsRealtime()) { + return; + } + + if (($fpBefore['draw_no'] ?? null) === ($fpAfter['draw_no'] ?? null) + && ($fpBefore['status'] ?? null) === ($fpAfter['status'] ?? null)) { + return; + } + + $this->notifyStatusChange($snapshotPayload); + } + + /** `draw.status_change`(管理端发布后等不与 tick 同路径时使用)。 */ + public function notifyStatusChange(?array $data): void + { + if (! $this->driverSupportsRealtime()) { + return; + } + + broadcast(new DrawStatusChangeBroadcast($data, (int) floor(microtime(true) * 1000))); + } + + /** `result.published` */ + public function notifyResultPublished(?array $data): void + { + if (! $this->driverSupportsRealtime()) { + return; + } + + broadcast(new DrawResultPublishedBroadcast($data, (int) floor(microtime(true) * 1000))); + } + + private function driverSupportsRealtime(): bool + { + $default = config('broadcasting.default'); + if ($default === null || $default === 'null') { + return false; + } + + $driver = config("broadcasting.connections.{$default}.driver") ?? $default; + + return ! in_array($driver, ['null', 'log'], true); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 456fce6..852cdc7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,7 @@ use App\Lottery\ErrorCode; use App\Support\ApiResponse; use App\Support\LotteryLocale; use Illuminate\Auth\AuthenticationException; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -32,6 +33,7 @@ return Application::configure(basePath: dirname(__DIR__)) // 自动加前缀 `api` + middleware `api`,见 routes/api.php api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', + channels: __DIR__.'/../routes/channels.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { @@ -161,4 +163,10 @@ return Application::configure(basePath: dirname(__DIR__)) 500, ); }); - })->create(); + }) + ->withSchedule(function (Schedule $schedule): void { + $schedule->command('lottery:draw-tick')->everyMinute(); + /** @see docs/01-界面文档.md §2.1 `draw.countdown` */ + $schedule->command('lottery:hall-countdown')->everySecond(); + }) + ->create(); diff --git a/composer.json b/composer.json index 6fce83c..c631aa0 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.3", "firebase/php-jwt": "^6.11", "laravel/framework": "^13.7", + "laravel/reverb": "^1.10", "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0" }, diff --git a/composer.lock b/composer.lock index 119db8e..139f364 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0921908c2ff678b179a811ae39a6c12b", + "content-hash": "d8d9d456c5d062cfd256fdf4112a87c8", "packages": [ { "name": "brick/math", @@ -103,6 +103,136 @@ }, "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "clue/redis-protocol", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/clue/redis-protocol.git", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -406,6 +536,53 @@ }, "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.11.1", @@ -1173,6 +1350,85 @@ }, "time": "2026-04-20T16:07:33+00:00" }, + { + "name": "laravel/reverb", + "version": "v1.10.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "a96310ae8b844d4862b2188a3cd6e79434893a6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/a96310ae8b844d4862b2188a3cd6e79434893a6b", + "reference": "a96310ae8b844d4862b2188a3cd6e79434893a6b", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.47|^11.0|^12.0|^13.0", + "illuminate/http": "^10.47|^11.0|^12.0|^13.0", + "illuminate/support": "^10.47|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.10.1" + }, + "time": "2026-04-30T12:07:26+00:00" + }, { "name": "laravel/sanctum", "version": "v4.3.2", @@ -2254,6 +2510,102 @@ }, "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "paragonie/sodium_compat", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/sodium_compat.git", + "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/4714da6efdc782c06690bc72ce34fae7941c2d9f", + "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f", + "shasum": "" + }, + "require": { + "php": "^8.1", + "php-64bit": "*" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^7|^8|^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "suggest": { + "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "ParagonIE\\Sodium\\": "namespaced/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com" + }, + { + "name": "Frank Denis", + "email": "jedisct1@pureftpd.org" + } + ], + "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", + "keywords": [ + "Authentication", + "BLAKE2b", + "ChaCha20", + "ChaCha20-Poly1305", + "Chapoly", + "Curve25519", + "Ed25519", + "EdDSA", + "Edwards-curve Digital Signature Algorithm", + "Elliptic Curve Diffie-Hellman", + "Poly1305", + "Pure-PHP cryptography", + "RFC 7748", + "RFC 8032", + "Salpoly", + "Salsa20", + "X25519", + "XChaCha20-Poly1305", + "XSalsa20-Poly1305", + "Xchacha20", + "Xsalsa20", + "aead", + "cryptography", + "ecdh", + "elliptic curve", + "elliptic curve cryptography", + "encryption", + "libsodium", + "php", + "public-key cryptography", + "secret-key cryptography", + "side-channel resistant" + ], + "support": { + "issues": "https://github.com/paragonie/sodium_compat/issues", + "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.0" + }, + "time": "2025-12-30T16:12:18+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -2750,6 +3102,67 @@ }, "time": "2026-03-22T23:03:24+00:00" }, + { + "name": "pusher/pusher-php-server", + "version": "7.2.7", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.2", + "paragonie/sodium_compat": "^1.6|^2.0", + "php": "^7.3|^8.0", + "psr/log": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7" + }, + "time": "2025-01-06T10:56:20+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -2930,6 +3343,595 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "ratchet/rfc6455", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/859d95f85dda0912c6d5b936d036d044e3af47ef", + "reference": "859d95f85dda0912c6d5b936d036d044e3af47ef", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.0" + }, + "time": "2025-02-24T01:18:22+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, { "name": "symfony/clock", "version": "v8.0.8", diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..ebc3fb9 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,82 @@ + env('BROADCAST_CONNECTION', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over WebSockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'reverb' => [ + 'driver' => 'reverb', + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), + 'encrypted' => true, + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/config/lottery.php b/config/lottery.php index cfbccf8..3996248 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -60,4 +60,25 @@ return [ 'token_ttl_days' => max(1, (int) env('ADMIN_API_TOKEN_TTL_DAYS', 7)), ], + /* + | 期号调度(GMT/业务日时区):生成计划、封盘与开奖时间点。 + | 与 PRD 「期号生成 / 封盘 / 开奖调度」链路一致;RNG 详见 DrawRngRunner。 + */ + 'draw' => [ + /** 盘面「业务日」切分与应用展示用 */ + 'timezone' => env('LOTTERY_DRAW_TIMEZONE', 'UTC'), + /** 开奖时间间隔(分钟),整日从 00:00 起排槽 */ + 'interval_minutes' => max(1, min(1440, (int) env('LOTTERY_DRAW_INTERVAL_MINUTES', 5))), + /** 下注开放时长(秒):start_time = close_time - betting_window_seconds */ + 'betting_window_seconds' => max(10, (int) env('LOTTERY_DRAW_BETTING_WINDOW_SECONDS', 270)), + /** 开奖前若干秒封盘:close_time = draw_time - 该值 */ + 'close_before_draw_seconds' => max(5, (int) env('LOTTERY_DRAW_CLOSE_BEFORE_SECONDS', 30)), + /** 预生成尚未开奖的期号数量(调度补齐);生产可调大,本地/联测建议 6–12 */ + 'buffer_draws_ahead' => max(1, (int) env('LOTTERY_DRAW_BUFFER_AHEAD', 8)), + /** true:RNG 后进入 review,需后台接口发布 */ + 'require_manual_review' => filter_var(env('LOTTERY_DRAW_REQUIRE_MANUAL_REVIEW', false), FILTER_VALIDATE_BOOLEAN), + /** 结果发布后的冷静期(分钟),{@see draws.cooling_end_time} */ + 'cooldown_minutes' => max(0, (int) env('LOTTERY_DRAW_COOLDOWN_MINUTES', 15)), + ], + ]; diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 0000000..91f3880 --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,102 @@ + env('REVERB_SERVER', 'reverb'), + + /* + |-------------------------------------------------------------------------- + | Reverb Servers + |-------------------------------------------------------------------------- + | + | Here you may define details for each of the supported Reverb servers. + | Each server has its own configuration options that are defined in + | the array below. You should ensure all the options are present. + | + */ + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + 'timeout' => env('REDIS_TIMEOUT', 60), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Reverb Applications + |-------------------------------------------------------------------------- + | + | Here you may define how Reverb applications are managed. If you choose + | to use the "config" provider, you may define an array of apps which + | your server will support, including their connection credentials. + | + */ + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'), + 'rate_limiting' => [ + 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), + 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), + 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), + 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), + ], + ], + ], + + ], + +]; diff --git a/database/migrations/2026_05_09_120000_migrate_draw_status_to_domain_dict.php b/database/migrations/2026_05_09_120000_migrate_draw_status_to_domain_dict.php new file mode 100644 index 0000000..54505e9 --- /dev/null +++ b/database/migrations/2026_05_09_120000_migrate_draw_status_to_domain_dict.php @@ -0,0 +1,22 @@ +where('status', 'pending_review')->update(['status' => 'review']); + DB::table('draws')->where('status', 'published')->update(['status' => 'cooldown']); + } + + public function down(): void + { + DB::table('draws')->where('status', 'review')->update(['status' => 'pending_review']); + DB::table('draws')->where('status', 'cooldown')->update(['status' => 'published']); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c44627c..a17d19e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,11 +18,12 @@ class DatabaseSeeder extends Seeder LotterySettingsSeeder::class, ]); - // 演示管理员 + 演示玩家:**勿在生产库执行**(或确保 APP_ENV≠production) + // 演示管理员 + 演示玩家 + 演示期号:**勿在生产库执行**(或确保 APP_ENV≠production) if (! app()->environment('production')) { $this->call([ AdminRbacAndUserSeeder::class, DevPlayerAndWalletSeeder::class, + DrawDemoSeeder::class, ]); } } diff --git a/database/seeders/DrawDemoSeeder.php b/database/seeders/DrawDemoSeeder.php new file mode 100644 index 0000000..6273352 --- /dev/null +++ b/database/seeders/DrawDemoSeeder.php @@ -0,0 +1,139 @@ +utc(); + $biz = $now->format('Y-m-d'); + $ymdCompact = str_replace('-', '', $biz); + + $this->seedFinishedDrawForResults($biz, $ymdCompact, $now); + $this->seedCurrentOpenDraw($biz, $ymdCompact, $now); + $this->seedFuturePendingDraw($biz, $ymdCompact, $now); + } + + private function seedFinishedDrawForResults(string $biz, string $ymdCompact, Carbon $now): void + { + $drawTime = $now->copy()->subHours(3); + + $draw = Draw::query()->updateOrCreate( + ['draw_no' => $ymdCompact.'-801'], + [ + 'business_date' => $biz, + 'sequence_no' => 801, + 'status' => DrawStatus::Settled->value, + 'start_time' => $drawTime->copy()->subMinutes(30), + 'close_time' => $drawTime->copy()->subSeconds(30), + 'draw_time' => $drawTime, + 'cooling_end_time' => $drawTime->copy()->addMinutes(15), + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ], + ); + + $batch = DrawResultBatch::query()->updateOrCreate( + [ + 'draw_id' => $draw->id, + 'result_version' => 1, + ], + [ + 'source_type' => 'rng', + 'rng_seed_hash' => hash('sha256', 'demo-draw-801-seed'), + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => $drawTime, + ], + ); + + DrawResultItem::query()->where('result_batch_id', $batch->id)->delete(); + + foreach (DrawPrizeLayout::slots() as $i => $slot) { + $n = (($i + 1) * 409) % 10_000; + $num = str_pad((string) $n, 4, '0', STR_PAD_LEFT); + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + } + + private function seedCurrentOpenDraw(string $biz, string $ymdCompact, Carbon $now): void + { + $drawTime = $now->copy()->addMinutes(20); + + Draw::query()->updateOrCreate( + ['draw_no' => $ymdCompact.'-802'], + [ + 'business_date' => $biz, + 'sequence_no' => 802, + 'status' => DrawStatus::Open->value, + 'start_time' => $now->copy()->subMinutes(5), + 'close_time' => $drawTime->copy()->subSeconds(30), + 'draw_time' => $drawTime, + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ], + ); + } + + private function seedFuturePendingDraw(string $biz, string $ymdCompact, Carbon $now): void + { + $start = $now->copy()->addMinutes(55); + $close = $start->copy()->addMinutes(12); + $draw = $close->copy()->addSeconds(30); + + Draw::query()->updateOrCreate( + ['draw_no' => $ymdCompact.'-803'], + [ + 'business_date' => $biz, + 'sequence_no' => 803, + 'status' => DrawStatus::Pending->value, + 'start_time' => $start, + 'close_time' => $close, + 'draw_time' => $draw, + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ], + ); + } +} diff --git a/routes/api.php b/routes/api.php index 36042c0..cda08f5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,10 +2,17 @@ use App\Http\Controllers\Api\V1\Admin\Auth\CaptchaController; use App\Http\Controllers\Api\V1\Admin\Auth\LoginController; +use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController; +use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController; +use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawShowController; +use App\Http\Controllers\Api\V1\Admin\Draw\DrawResultBatchPublishController; use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController; use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController; use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController; use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController; +use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; +use App\Http\Controllers\Api\V1\Draw\DrawResultShowController; +use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController; use App\Http\Controllers\Api\V1\HealthController; use App\Http\Controllers\Api\V1\Player\MeController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; @@ -23,6 +30,14 @@ Route::prefix('v1')->group(function (): void { // 名称:服务健康检查 Route::get('health', HealthController::class)->name('api.v1.health'); + // 名称:当前期号(下注大厅倒计时;无需登录) + 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}') + ->name('api.v1.draw.results.show'); + Route::prefix('player') ->name('api.v1.player.') ->group(function (): void { @@ -73,6 +88,16 @@ Route::prefix('v1')->group(function (): void { ->name('wallet.transactions'); Route::get('players/{player}/wallets', PlayerWalletShowController::class) ->name('players.wallets'); + // 期号:列表 / 详情 / 批次(开奖结果与审核数据) + Route::get('draws', AdminDrawIndexController::class)->name('draws.index'); + Route::get('draws/{draw}', AdminDrawShowController::class)->name('draws.show'); + Route::get('draws/{draw}/result-batches', AdminDrawResultBatchesIndexController::class) + ->name('draws.result-batches.index'); + // 名称:发布待审核开奖批次(人工审核) + Route::post( + 'draws/{draw}/result-batches/{batch}/publish', + DrawResultBatchPublishController::class, + )->name('draws.result-batches.publish'); }); }); }); diff --git a/routes/channels.php b/routes/channels.php new file mode 100644 index 0000000..df2ad28 --- /dev/null +++ b/routes/channels.php @@ -0,0 +1,7 @@ +id === (int) $id; +}); diff --git a/tests/Feature/AdminDrawApiTest.php b/tests/Feature/AdminDrawApiTest.php new file mode 100644 index 0000000..32df22a --- /dev/null +++ b/tests/Feature/AdminDrawApiTest.php @@ -0,0 +1,144 @@ +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(); +}); diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php new file mode 100644 index 0000000..d186211 --- /dev/null +++ b/tests/Feature/DrawPipelineTest.php @@ -0,0 +1,398 @@ + '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(); +}); diff --git a/tests/Feature/DrawResultsApiTest.php b/tests/Feature/DrawResultsApiTest.php new file mode 100644 index 0000000..003265b --- /dev/null +++ b/tests/Feature/DrawResultsApiTest.php @@ -0,0 +1,134 @@ +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'); +});