feat: 添加 Laravel Reverb 支持,更新 .env.example 文件以配置 WebSocket,增强彩票调度功能,更新 API 路由以支持期号管理与结果发布
This commit is contained in:
17
.env.example
17
.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 名
|
||||
|
||||
28
README.md
28
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).
|
||||
|
||||
32
app/Console/Commands/LotteryDrawTickCommand.php
Normal file
32
app/Console/Commands/LotteryDrawTickCommand.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Draw\DrawTickService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class LotteryDrawTickCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:draw-tick';
|
||||
|
||||
protected $description = '封盘、开奖 RNG、补齐期号缓冲(每分钟调度入口)';
|
||||
|
||||
public function handle(DrawTickService $tickService): int
|
||||
{
|
||||
$report = $tickService->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;
|
||||
}
|
||||
}
|
||||
20
app/Console/Commands/LotteryHallCountdownCommand.php
Normal file
20
app/Console/Commands/LotteryHallCountdownCommand.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class LotteryHallCountdownCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:hall-countdown';
|
||||
|
||||
protected $description = '大厅 countdown WebSocket:`draw.countdown`(每秒;见界面文档 §2.1)';
|
||||
|
||||
public function handle(LotteryHallRealtimeBroadcaster $broadcaster): int
|
||||
{
|
||||
$broadcaster->countdownPulse();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
43
app/Events/DrawCountdownBroadcast.php
Normal file
43
app/Events/DrawCountdownBroadcast.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/** 界面文档 §2.1:`draw.countdown` */
|
||||
class DrawCountdownBroadcast implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $data 与 GET draw/current 的 data 相同
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?array $data,
|
||||
public readonly int $emittedAtMs,
|
||||
) {}
|
||||
|
||||
/** @return array<int, Channel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new Channel('lottery-hall')];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'draw.countdown';
|
||||
}
|
||||
|
||||
/** @return array{data: array<string, mixed>|null, emitted_at_ms: int} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->data,
|
||||
'emitted_at_ms' => $this->emittedAtMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Events/DrawResultPublishedBroadcast.php
Normal file
43
app/Events/DrawResultPublishedBroadcast.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/** 界面文档 §2.1:`result.published` */
|
||||
class DrawResultPublishedBroadcast implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $data 与 GET draw/current 的 data 相同(含 result_items)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?array $data,
|
||||
public readonly int $emittedAtMs,
|
||||
) {}
|
||||
|
||||
/** @return array<int, Channel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new Channel('lottery-hall')];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'result.published';
|
||||
}
|
||||
|
||||
/** @return array{data: array<string, mixed>|null, emitted_at_ms: int} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->data,
|
||||
'emitted_at_ms' => $this->emittedAtMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Events/DrawStatusChangeBroadcast.php
Normal file
43
app/Events/DrawStatusChangeBroadcast.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/** 界面文档 §2.1:`draw.status_change` */
|
||||
class DrawStatusChangeBroadcast implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $data 与 GET draw/current 的 data 相同
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?array $data,
|
||||
public readonly int $emittedAtMs,
|
||||
) {}
|
||||
|
||||
/** @return array<int, Channel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new Channel('lottery-hall')];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'draw.status_change';
|
||||
}
|
||||
|
||||
/** @return array{data: array<string, mixed>|null, emitted_at_ms: int} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->data,
|
||||
'emitted_at_ms' => $this->emittedAtMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Draw;
|
||||
use App\Support\ApiResponse;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/draws — 期号列表。
|
||||
*/
|
||||
final class AdminDrawIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min(max((int) $request->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<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/draws/{draw}/result-batches — 开奖批次与号码(审核/结果核对)。
|
||||
*/
|
||||
final class AdminDrawResultBatchesIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Draw $draw): JsonResponse
|
||||
{
|
||||
$batches = $draw->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<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Support\ApiResponse;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/draws/{draw} — 当期状态明细(后台查看 DB 为准 + 大厅展示态预览)。
|
||||
*/
|
||||
final class AdminDrawShowController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawHallSnapshotBuilder $hallPreview,
|
||||
) {}
|
||||
|
||||
public function __invoke(Draw $draw): JsonResponse
|
||||
{
|
||||
$nowUtc = now()->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Services\Draw\DrawPublishService;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/draws/{draw}/result-batches/{batch}/publish — 人工审核发布 RNG 批次。
|
||||
*/
|
||||
class DrawResultBatchPublishController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawPublishService $publishService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, Draw $draw, DrawResultBatch $batch): JsonResponse
|
||||
{
|
||||
$admin = $request->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php
Normal file
24
app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Draw;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 下注大厅:`GET /api/v1/draw/current`。
|
||||
*/
|
||||
class DrawCurrentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawHallSnapshotBuilder $snapshot,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::success($this->snapshot->build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Draw;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Draw;
|
||||
use App\Services\Draw\DrawResultViewService;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* `GET /api/v1/draw/results/{draw_no}` — 单期详情(便于玩家端[< >]切换)。
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Draw;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Services\Draw\DrawResultViewService;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* `GET /api/v1/draw/results` — 已发布开奖往期(公开;对齐 PRD `/api/v1/results`)。
|
||||
*/
|
||||
class DrawResultsIndexController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawResultViewService $viewer,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = max(1, min(50, (int) $request->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<int, Draw> $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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
app/Lottery/DrawResultBatchStatus.php
Normal file
16
app/Lottery/DrawResultBatchStatus.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Lottery;
|
||||
|
||||
/** 开奖批次状态 {@see draw_result_batches.status} */
|
||||
enum DrawResultBatchStatus: string
|
||||
{
|
||||
/** RNG/人工录入完成,等待审核 */
|
||||
case PendingReview = 'pending_review';
|
||||
|
||||
/** 已发布为当期有效结果 */
|
||||
case Published = 'published';
|
||||
|
||||
/** 审核驳回(本阶段仅占位) */
|
||||
case Rejected = 'rejected';
|
||||
}
|
||||
10
app/Lottery/DrawResultSourceType.php
Normal file
10
app/Lottery/DrawResultSourceType.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Lottery;
|
||||
|
||||
enum DrawResultSourceType: string
|
||||
{
|
||||
case Rng = 'rng';
|
||||
|
||||
case Manual = 'manual';
|
||||
}
|
||||
39
app/Lottery/DrawStatus.php
Normal file
39
app/Lottery/DrawStatus.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Lottery;
|
||||
|
||||
/**
|
||||
* 期号状态 {@see draws.status} — 与《04-领域字典》draw_status、产品文档 §7.1 对齐。
|
||||
*/
|
||||
enum DrawStatus: string
|
||||
{
|
||||
/** pending — 未开始 */
|
||||
case Pending = 'pending';
|
||||
|
||||
/** open — 可下注 */
|
||||
case Open = 'open';
|
||||
|
||||
/** closing — 封盘中(已停止接受新注单,开奖时刻未到) */
|
||||
case Closing = 'closing';
|
||||
|
||||
/** closed — 已封盘待开奖(已到计划开奖时刻,等待 RNG/Lua 开奖) */
|
||||
case Closed = 'closed';
|
||||
|
||||
/** drawing — 开奖处理中(正在生成结果批次) */
|
||||
case Drawing = 'drawing';
|
||||
|
||||
/** review — 待人工审核(可配置 RNG 后直接发布则无此态) */
|
||||
case Review = 'review';
|
||||
|
||||
/** cooldown — 冷静期(结果已发布后的冻结窗口) */
|
||||
case Cooldown = 'cooldown';
|
||||
|
||||
/** settling — 结算处理中(派彩链路,阶段 4 推进) */
|
||||
case Settling = 'settling';
|
||||
|
||||
/** settled — 已结算 */
|
||||
case Settled = 'settled';
|
||||
|
||||
/** cancelled — 已取消 */
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
55
app/Models/Draw.php
Normal file
55
app/Models/Draw.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/** 彩票期号 {@see draws} */
|
||||
class Draw extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'draw_no',
|
||||
'business_date',
|
||||
'sequence_no',
|
||||
'status',
|
||||
'start_time',
|
||||
'close_time',
|
||||
'draw_time',
|
||||
'cooling_end_time',
|
||||
'result_source',
|
||||
'current_result_version',
|
||||
'settle_version',
|
||||
'is_reopened',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'business_date' => '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);
|
||||
}
|
||||
}
|
||||
49
app/Models/DrawResultBatch.php
Normal file
49
app/Models/DrawResultBatch.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/** 开奖结果批次(含 RNG 种子摘要、审核人) {@see draw_result_batches} */
|
||||
class DrawResultBatch extends Model
|
||||
{
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'draw_id',
|
||||
'result_version',
|
||||
'source_type',
|
||||
'rng_seed_hash',
|
||||
'raw_seed_encrypted',
|
||||
'status',
|
||||
'created_by',
|
||||
'confirmed_by',
|
||||
'confirmed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'result_version' => '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);
|
||||
}
|
||||
}
|
||||
43
app/Models/DrawResultItem.php
Normal file
43
app/Models/DrawResultItem.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** 单条中奖号码行(与界面文档奖项分区对应) {@see draw_result_items} */
|
||||
class DrawResultItem extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'draw_id',
|
||||
'result_batch_id',
|
||||
'prize_type',
|
||||
'prize_index',
|
||||
'number_4d',
|
||||
'suffix_3d',
|
||||
'suffix_2d',
|
||||
'head_digit',
|
||||
'tail_digit',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'prize_index' => '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');
|
||||
}
|
||||
}
|
||||
182
app/Services/Draw/DrawHallSnapshotBuilder.php
Normal file
182
app/Services/Draw/DrawHallSnapshotBuilder.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* `GET draw/current` 与大厅 WS 快照共用数据结构。
|
||||
*
|
||||
* @return array<string, mixed>|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<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
168
app/Services/Draw/DrawPlannerService.php
Normal file
168
app/Services/Draw/DrawPlannerService.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 按计划生成未来的 `draws` 行(期号、时间表)。
|
||||
*/
|
||||
final class DrawPlannerService
|
||||
{
|
||||
/** @return array{created: int, buffer_target: int, upcoming: int} */
|
||||
public function ensureBuffer(?Carbon $now = null): array
|
||||
{
|
||||
$nowUtc = ($now ?? Carbon::now())->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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Services/Draw/DrawPrizeLayout.php
Normal file
27
app/Services/Draw/DrawPrizeLayout.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
/**
|
||||
* 开奖号码行布局(与界面文档 4.6 奖项分区一致)。
|
||||
*
|
||||
* @return array<int, array{prize_type: string, prize_index: int}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
89
app/Services/Draw/DrawPublishService.php
Normal file
89
app/Services/Draw/DrawPublishService.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 人工审核通过后发布结果;或 RNG 自动生成路径内联调用同一事务字段更新。
|
||||
*/
|
||||
final class DrawPublishService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
|
||||
private readonly DrawHallSnapshotBuilder $snapshot,
|
||||
) {}
|
||||
|
||||
public function publishManualBatch(DrawResultBatch $batch, AdminUser $admin): Draw
|
||||
{
|
||||
$draw = DB::transaction(function () use ($batch, $admin): Draw {
|
||||
/** @var DrawResultBatch $lockedBatch */
|
||||
$lockedBatch = DrawResultBatch::query()->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();
|
||||
}
|
||||
}
|
||||
169
app/Services/Draw/DrawResultViewService.php
Normal file
169
app/Services/Draw/DrawResultViewService.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 将已发布的 {@see DrawResultItem} 聚合成前端/文档约定结构。
|
||||
*/
|
||||
final class DrawResultViewService
|
||||
{
|
||||
/**
|
||||
* 与 `docs/01-产品文档` GET /api/v1/results 示例键名对齐(1st/2nd/3rd/starter/consolation)。
|
||||
*
|
||||
* @return array{
|
||||
* 1st: string,
|
||||
* 2nd: string,
|
||||
* 3rd: string,
|
||||
* starter: array<int, string>,
|
||||
* consolation: array<int, string>
|
||||
* }
|
||||
*/
|
||||
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<string, mixed>|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<int, Draw> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
116
app/Services/Draw/DrawRngRunner.php
Normal file
116
app/Services/Draw/DrawRngRunner.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawResultSourceType;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 按配置执行 RNG,写入 {@see DrawResultBatch} / {@see DrawResultItem}。
|
||||
*/
|
||||
final class DrawRngRunner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawPublishService $publisher,
|
||||
) {}
|
||||
|
||||
/** 已对单期加锁外层调用时使用 */
|
||||
public function executeLocked(Draw $draw): DrawResultBatch
|
||||
{
|
||||
$draw->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<int, string>}
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
134
app/Services/Draw/DrawTickService.php
Normal file
134
app/Services/Draw/DrawTickService.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* 每分钟调度:期号状态推进 → RNG(若到期号)→ 冷静期结束时进入结算态 → 补齐未来缓冲。
|
||||
*
|
||||
* @see 《04-领域字典》draw_status
|
||||
*/
|
||||
final class DrawTickService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawPlannerService $planner,
|
||||
private readonly DrawRngRunner $rng,
|
||||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||||
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status_updates: array<string, int>,
|
||||
* rng_rung: int,
|
||||
* rng_errors: array<int, string>,
|
||||
* planned: array<string, int>
|
||||
* }
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
80
app/Services/Draw/LotteryHallRealtimeBroadcaster.php
Normal file
80
app/Services/Draw/LotteryHallRealtimeBroadcaster.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Events\DrawCountdownBroadcast;
|
||||
use App\Events\DrawResultPublishedBroadcast;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
|
||||
/**
|
||||
* 对齐界面文档 §2.1:`draw.countdown`、`draw.status_change`、`result.published`(频道 `lottery-hall`)。
|
||||
*/
|
||||
final class LotteryHallRealtimeBroadcaster
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawHallSnapshotBuilder $snapshot,
|
||||
) {}
|
||||
|
||||
/** 每秒调度:`draw.countdown` */
|
||||
public function countdownPulse(): void
|
||||
{
|
||||
if (! $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
1004
composer.lock
generated
1004
composer.lock
generated
@@ -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",
|
||||
|
||||
82
config/broadcasting.php
Normal file
82
config/broadcasting.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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)),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
102
config/reverb.php
Normal file
102
config/reverb.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => 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),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 将旧版期号状态值迁移为《04-领域字典》约定。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('draws')->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']);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
139
database/seeders/DrawDemoSeeder.php
Normal file
139
database/seeders/DrawDemoSeeder.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* 【本地演示】写入若干条 `draws` + 一条带完整 23 组开奖,便于前端大厅 / 开奖结果联调。
|
||||
*
|
||||
* - 一页「当期」:**open**,封盘时间晚于此刻,玩家在 `/hall` 可见倒计时。
|
||||
* - 一条 **pending**(将来期)排在后面。
|
||||
* - 一条 **settled**(已结算)附 `draw_result_batches` / `draw_result_items`,`/api/v1/draw/results` 有数据。
|
||||
*
|
||||
* ```bash
|
||||
* php artisan db:seed --class="Database\\Seeders\\DrawDemoSeeder"
|
||||
* ```
|
||||
*/
|
||||
class DrawDemoSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$now = Carbon::now()->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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
7
routes/channels.php
Normal file
7
routes/channels.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
||||
return (int) $user->id === (int) $id;
|
||||
});
|
||||
144
tests/Feature/AdminDrawApiTest.php
Normal file
144
tests/Feature/AdminDrawApiTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function mintAdminBearer(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_pages_admin',
|
||||
'name' => 'Draw QA',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('admin draws index requires authentication', function (): void {
|
||||
$this->getJson('/api/v1/admin/draws')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('admin draws index returns pagination', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:00:00', 'UTC'));
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-001',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 1,
|
||||
'status' => 'pending',
|
||||
'start_time' => now()->copy()->addHour(),
|
||||
'close_time' => null,
|
||||
'draw_time' => now()->copy()->addHours(2),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$token = mintAdminBearer();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/draws?per_page=5')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.meta.total', 1)
|
||||
->assertJsonPath('data.items.0.draw_no', '20260509-001');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin draw show exposes hall preview status', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC'));
|
||||
$drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');
|
||||
$closeTime = $drawTime->copy()->subSeconds(30);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-802',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 802,
|
||||
'status' => 'open',
|
||||
'start_time' => $closeTime->copy()->subMinutes(5),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$token = mintAdminBearer();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/draws/'.$draw->id)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-802')
|
||||
->assertJsonPath('data.status', 'open')
|
||||
->assertJsonPath('data.hall_preview_status', 'closing');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin draw result batches lists items', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 16:10:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-400',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 400,
|
||||
'status' => 'cooldown',
|
||||
'start_time' => now()->copy()->subHour(),
|
||||
'close_time' => now()->copy()->subMinutes(40),
|
||||
'draw_time' => now()->copy()->subMinutes(20),
|
||||
'cooling_end_time' => now()->copy()->addMinutes(10),
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => hash('sha256', 'x'),
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => 'first',
|
||||
'prize_index' => 0,
|
||||
'number_4d' => '1234',
|
||||
'suffix_3d' => '234',
|
||||
'suffix_2d' => '34',
|
||||
'head_digit' => 1,
|
||||
'tail_digit' => 4,
|
||||
]);
|
||||
|
||||
$token = mintAdminBearer();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/draws/'.$draw->id.'/result-batches')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-400')
|
||||
->assertJsonPath('data.batches.0.result_version', 1)
|
||||
->assertJsonPath('data.batches.0.items.0.number_4d', '1234');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
398
tests/Feature/DrawPipelineTest.php
Normal file
398
tests/Feature/DrawPipelineTest.php
Normal file
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Events\DrawCountdownBroadcast;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Services\Draw\DrawPlannerService;
|
||||
use App\Services\Draw\DrawTickService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config([
|
||||
'lottery.draw.timezone' => 'UTC',
|
||||
'lottery.draw.interval_minutes' => 60,
|
||||
'lottery.draw.buffer_draws_ahead' => 3,
|
||||
'lottery.draw.betting_window_seconds' => 270,
|
||||
'lottery.draw.close_before_draw_seconds' => 30,
|
||||
'lottery.draw.require_manual_review' => false,
|
||||
'lottery.draw.cooldown_minutes' => 15,
|
||||
]);
|
||||
});
|
||||
|
||||
test('draw planner fills buffer rows with ordered draw_no', function (): void {
|
||||
$fixed = Carbon::parse('2026-05-09 12:00:00', 'UTC')->utc();
|
||||
|
||||
/** @var DrawPlannerService $planner */
|
||||
$planner = app(DrawPlannerService::class);
|
||||
$report = $planner->ensureBuffer($fixed);
|
||||
|
||||
expect($report['created'])->toBeGreaterThan(0);
|
||||
expect(Draw::query()->count())->toBe($report['upcoming']);
|
||||
|
||||
$drawNos = Draw::query()->orderBy('draw_time')->pluck('draw_no')->all();
|
||||
$sorted = $drawNos;
|
||||
sort($sorted);
|
||||
expect($drawNos)->toEqual($sorted);
|
||||
});
|
||||
|
||||
test('draw tick moves open draw to closing when close_time passed before draw_time', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->addMinutes(30);
|
||||
$closeTime = now()->copy()->subMinute();
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-099',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 99,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(50),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
$draw = Draw::query()->where('draw_no', '20260509-099')->firstOrFail();
|
||||
|
||||
expect($draw->status)->toBe(DrawStatus::Closing->value);
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(0);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('draw tick rng publishes result when manual review disabled', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => false]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:05:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->subMinute();
|
||||
$closeTime = $drawTime->copy()->subSeconds(30);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-200',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 200,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(10),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
$draw = Draw::query()->where('draw_no', '20260509-200')->firstOrFail();
|
||||
expect($draw->status)->toBe(DrawStatus::Cooldown->value);
|
||||
expect($draw->current_result_version)->toBe(1);
|
||||
expect($draw->cooling_end_time)->not->toBeNull();
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
|
||||
expect($batch->items()->count())->toBe(23);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('draw tick rng awaits manual publish when review enabled', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:06:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->subMinute();
|
||||
$closeTime = $drawTime->copy()->subSeconds(30);
|
||||
|
||||
$drawRow = Draw::query()->create([
|
||||
'draw_no' => '20260509-201',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 201,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(10),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
$drawRow->refresh();
|
||||
expect($drawRow->status)->toBe(DrawStatus::Review->value);
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $drawRow->id)->firstOrFail();
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::PendingReview->value);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_auditor',
|
||||
'name' => 'Auditor',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$batch->id}/publish")
|
||||
->assertOk();
|
||||
|
||||
$drawRow->refresh();
|
||||
$batch->refresh();
|
||||
expect($drawRow->status)->toBe(DrawStatus::Cooldown->value);
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
|
||||
expect($drawRow->current_result_version)->toBe(1);
|
||||
expect($drawRow->cooling_end_time)->not->toBeNull();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('cooldown expiry tick moves draw to settling', function (): void {
|
||||
config([
|
||||
'lottery.draw.require_manual_review' => false,
|
||||
'lottery.draw.cooldown_minutes' => 15,
|
||||
]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->subMinute();
|
||||
$closeTime = $drawTime->copy()->subSeconds(30);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-777',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 777,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(10),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
$draw = Draw::query()->where('draw_no', '20260509-777')->firstOrFail();
|
||||
expect($draw->status)->toBe(DrawStatus::Cooldown->value);
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:01', 'UTC')->addMinutes(16));
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Settling->value);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('GET draw current returns open draw with seconds to close', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 15:00:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->addHour();
|
||||
$closeTime = $drawTime->copy()->subSeconds(30);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-300',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 300,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(5),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-300')
|
||||
->assertJsonPath('data.status', DrawStatus::Open->value)
|
||||
->assertJsonPath('data.seconds_to_close', 60 * 60 - 30)
|
||||
->assertJsonPath('data.seconds_to_draw', 3600);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('GET draw current exposes closing when row is open in DB but close_time has passed', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC'));
|
||||
$drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');
|
||||
$closeTime = $drawTime->copy()->subSeconds(30);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-310',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 310,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(5),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-310')
|
||||
->assertJsonPath('data.status', DrawStatus::Closing->value)
|
||||
->assertJsonPath('data.seconds_to_close', 0)
|
||||
->assertJsonPath('data.seconds_to_draw', 20);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('GET draw current exposes closed when row is open in DB but draw_time has passed', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 16:31:00', 'UTC'));
|
||||
$drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC');
|
||||
$closeTime = $drawTime->copy()->subSeconds(30);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-311',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 311,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(5),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-311')
|
||||
->assertJsonPath('data.status', DrawStatus::Closed->value)
|
||||
->assertJsonPath('data.seconds_to_close', 0)
|
||||
->assertJsonPath('data.seconds_to_draw', 0);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('GET draw current includes result_items when cooldown', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 16:10:00', 'UTC'));
|
||||
|
||||
$drawRow = Draw::query()->create([
|
||||
'draw_no' => '20260509-400',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 400,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => now()->copy()->subHour(),
|
||||
'close_time' => now()->copy()->subMinutes(30),
|
||||
'draw_time' => now()->copy()->subMinutes(20),
|
||||
'cooling_end_time' => now()->copy()->addMinutes(10),
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $drawRow->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => hash('sha256', 'fixture'),
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $drawRow->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => 'first',
|
||||
'prize_index' => 0,
|
||||
'number_4d' => '1234',
|
||||
'suffix_3d' => '234',
|
||||
'suffix_2d' => '34',
|
||||
'head_digit' => 1,
|
||||
'tail_digit' => 4,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', DrawStatus::Cooldown->value)
|
||||
->assertJsonPath('data.result_items.0.number_4d', '1234');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('lottery draw-tick command runs successfully', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2030-06-01 12:00:00', 'UTC'));
|
||||
$this->artisan('lottery:draw-tick')->assertSuccessful();
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('lottery hall-countdown dispatches draw.countdown when using reverb connection', function (): void {
|
||||
Event::fake([DrawCountdownBroadcast::class]);
|
||||
config([
|
||||
'broadcasting.default' => 'reverb',
|
||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||
]);
|
||||
|
||||
$this->artisan('lottery:hall-countdown')->assertSuccessful();
|
||||
|
||||
Event::assertDispatched(DrawCountdownBroadcast::class);
|
||||
});
|
||||
|
||||
test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void {
|
||||
Event::fake([DrawStatusChangeBroadcast::class]);
|
||||
config([
|
||||
'broadcasting.default' => 'reverb',
|
||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||
]);
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->addMinutes(30);
|
||||
$closeTime = now()->copy()->subMinute();
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-099',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 99,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => $closeTime->copy()->subMinutes(50),
|
||||
'close_time' => $closeTime,
|
||||
'draw_time' => $drawTime,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
Event::assertDispatched(DrawStatusChangeBroadcast::class);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
134
tests/Feature/DrawResultsApiTest.php
Normal file
134
tests/Feature/DrawResultsApiTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedMinimalPublishedDraw(array $attrs, string $digit): Draw
|
||||
{
|
||||
$draw = Draw::query()->create($attrs);
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => hash('sha256', $digit),
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => 'first',
|
||||
'prize_index' => 0,
|
||||
'number_4d' => str_repeat($digit, 4),
|
||||
'suffix_3d' => str_repeat($digit, 3),
|
||||
'suffix_2d' => str_repeat($digit, 2),
|
||||
'head_digit' => (int) $digit,
|
||||
'tail_digit' => (int) $digit,
|
||||
]);
|
||||
|
||||
return $draw->fresh();
|
||||
}
|
||||
|
||||
test('draw results index returns published draws with PRD shaped results', function (): void {
|
||||
$draw = seedMinimalPublishedDraw([
|
||||
'draw_no' => '20260509-111',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 111,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => now()->subHour(),
|
||||
'close_time' => now()->subMinutes(45),
|
||||
'draw_time' => now()->subMinutes(30),
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
], '8');
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
foreach (['second', 'third'] as $tier) {
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $tier,
|
||||
'prize_index' => 0,
|
||||
'number_4d' => $tier === 'second' ? '7777' : '6666',
|
||||
'suffix_3d' => '777',
|
||||
'suffix_2d' => '77',
|
||||
'head_digit' => 7,
|
||||
'tail_digit' => 7,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->getJson('/api/v1/draw/results?per_page=5')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.items.0.draw_no', '20260509-111')
|
||||
->assertJsonPath('data.items.0.results.1st', '8888')
|
||||
->assertJsonPath('data.items.0.results.2nd', '7777')
|
||||
->assertJsonPath('data.items.0.results.3rd', '6666');
|
||||
});
|
||||
|
||||
test('draw result show includes neighbor draw numbers', function (): void {
|
||||
$t0 = now()->subHours(3);
|
||||
seedMinimalPublishedDraw([
|
||||
'draw_no' => '20260509-100',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 100,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => $t0,
|
||||
'close_time' => $t0,
|
||||
'draw_time' => $t0->copy()->addSecond(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
], '1');
|
||||
|
||||
$t1 = now()->subHours(2);
|
||||
seedMinimalPublishedDraw([
|
||||
'draw_no' => '20260509-101',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 101,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => $t1,
|
||||
'close_time' => $t1,
|
||||
'draw_time' => $t1->copy()->addSecond(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
], '2');
|
||||
|
||||
$t2 = now()->subHour();
|
||||
seedMinimalPublishedDraw([
|
||||
'draw_no' => '20260509-102',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 102,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => $t2,
|
||||
'close_time' => $t2,
|
||||
'draw_time' => $t2->copy()->addSecond(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
], '3');
|
||||
|
||||
$this->getJson('/api/v1/draw/results/20260509-101')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.previous_draw_no', '20260509-100')
|
||||
->assertJsonPath('data.next_draw_no', '20260509-102');
|
||||
});
|
||||
Reference in New Issue
Block a user