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

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

View File

@@ -0,0 +1,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;
}
}

View 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;
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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,
];
}
}

View File

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

View File

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

View File

@@ -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,
]);
}
}

View File

@@ -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,
]);
}
}

View 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());
}
}

View File

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

View File

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

View 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';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Lottery;
enum DrawResultSourceType: string
{
case Rng = 'rng';
case Manual = 'manual';
}

View 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
View 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);
}
}

View 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);
}
}

View 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');
}
}

View 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;
}
}

View 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,
];
}
}

View 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;
}
}

View 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();
}
}

View 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,
];
}
}

View 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];
}
}

View 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]);
}
}

View 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);
}
}