feat: 添加 Laravel Reverb 支持,更新 .env.example 文件以配置 WebSocket,增强彩票调度功能,更新 API 路由以支持期号管理与结果发布
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user