Files
lotteryLaravel/app/Services/Draw/DrawHallSnapshotBuilder.php
kang a60ce8caad refactor: 优化 DrawHallSnapshotBuilder 和权限管理逻辑
- 在 DrawHallSnapshotBuilder 中简化数据获取逻辑,仅保留必要字段,更新状态表示方式。
- 在 AdminAuthorizationRegistry 中整合接入站点权限定义,提升权限管理的灵活性与可维护性。
- 更新调度任务配置,确保任务在单一服务器上运行,避免重叠执行,提高系统稳定性。
- 增强测试用例,确保新逻辑的正确性与稳定性。
2026-05-27 16:51:08 +08:00

330 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Jackpot\JackpotSummaryService;
/**
* `GET draw/current` 与大厅 WS 快照共用数据结构。
*
* @return array<string, mixed>|null
*/
final class DrawHallSnapshotBuilder
{
public function __construct(
private readonly JackpotSummaryService $jackpotSummary,
) {}
/**
* Tick 未及时跑时DB 仍为 `open` 但已到封盘时刻;对外快照与界面应对齐真实可下注态(见 DrawTickService::openToClosingOrClosed
*
* 后台「当前大厅可见状态」预览可共用本方法。
*/
public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string
{
$db = (string) $target->status;
if ($db === DrawStatus::Pending->value) {
$startUtc = $target->start_time;
if ($startUtc instanceof Carbon && $startUtc <= $nowUtc) {
$closeUtc = $target->close_time;
if ($closeUtc === null || $closeUtc > $nowUtc) {
$db = DrawStatus::Open->value;
}
} else {
return $db;
}
}
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;
}
/** 与大厅 {@see effectiveHallDisplayStatus} 一致:是否仍接受 preview/place。 */
public function isBettingOpen(Draw $draw, ?Carbon $nowUtc = null): bool
{
$nowUtc ??= now()->utc();
return $this->effectiveHallDisplayStatus($draw, $nowUtc) === DrawStatus::Open->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(function ($q) use ($nowUtc): void {
$q->where('status', DrawStatus::Open->value)
->orWhere(function ($q2) use ($nowUtc): void {
$q2->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);
})
->orderBy('draw_time')
->first();
if ($bettingOpen !== null) {
return $bettingOpen;
}
$upcoming = Draw::query()
->whereNotIn('status', [
DrawStatus::Settled->value,
DrawStatus::Cancelled->value,
])
->where(function ($q) use ($nowUtc): void {
$q->where(function ($q2) use ($nowUtc): void {
$q2->whereNotNull('close_time')
->where('close_time', '>', $nowUtc);
})->orWhere(function ($q2) use ($nowUtc): void {
$q2->whereNull('close_time')
->whereNotNull('draw_time')
->where('draw_time', '>', $nowUtc);
});
})
->orderBy('draw_time')
->get();
foreach ($upcoming as $candidate) {
if ($this->isStalePendingRow($candidate, $nowUtc)) {
continue;
}
return $candidate;
}
$chronological = Draw::query()
->whereNotIn('status', [
DrawStatus::Settled->value,
DrawStatus::Cancelled->value,
])
->orderByDesc('draw_time')
->first();
if ($chronological !== null && $this->isCooldownExpired($chronological, $nowUtc)) {
$next = Draw::query()
->whereNotIn('status', [
DrawStatus::Settled->value,
DrawStatus::Cancelled->value,
])
->where('draw_time', '>', $chronological->draw_time)
->orderBy('draw_time')
->first();
if ($next !== null) {
return $next;
}
}
return $chronological;
}
/** 调度未跑时:库内仍是 pending但封盘/开奖时刻已过,不应再作为大厅「当期」。 */
private function isStalePendingRow(Draw $draw, Carbon $nowUtc): bool
{
if ((string) $draw->status !== DrawStatus::Pending->value) {
return false;
}
$closeUtc = $draw->close_time;
if ($closeUtc instanceof Carbon && $closeUtc <= $nowUtc) {
return true;
}
$drawUtc = $draw->draw_time;
return $drawUtc instanceof Carbon && $drawUtc <= $nowUtc;
}
private function isCooldownExpired(Draw $draw, Carbon $nowUtc): bool
{
return (string) $draw->status === DrawStatus::Cooldown->value
&& $draw->cooling_end_time instanceof Carbon
&& $draw->cooling_end_time <= $nowUtc;
}
/**
* {@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, ?string $currencyCode = null): ?array
{
$nowUtc = ($nowUtc ?? Carbon::now())->utc();
$currencyCode = $this->normalizeCurrencyCode($currencyCode);
$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;
$startUtc = $target->start_time;
$secsToStart = ($startUtc !== null && $startUtc > $nowUtc)
? max(0, (int) $startUtc->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);
$scheduleTz = (string) config('lottery.draw.timezone', 'UTC');
$payload = [
'schedule_timezone' => $scheduleTz,
'schedule_now' => $nowUtc->copy()->timezone($scheduleTz)->format('Y-m-d H:i:s'),
'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_start' => $secsToStart,
'seconds_to_draw' => $secsToDraw,
'cooling_end_time' => $target->cooling_end_time?->toIso8601String(),
'seconds_remaining_in_cooldown' => $coolingRemain,
'jackpot_currency_code' => $currencyCode,
'jackpot' => $this->jackpotSummary->summary($currencyCode),
];
$riskAlerts = RiskPool::query()
->where('draw_id', $target->id)
->where(function ($q): void {
$q->where('sold_out_status', 1)
->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8');
})
->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC')
->orderByDesc('locked_amount')
->orderBy('normalized_number')
->limit(500)
->get(['normalized_number', 'sold_out_status'])
->map(fn ($row) => [
'normalized_number' => (string) $row->normalized_number,
'status' => (int) $row->sold_out_status === 1 ? 'sold_out' : 'warning',
])
->values()
->all();
$payload['risk_pool_alerts'] = $riskAlerts;
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;
}
private function normalizeCurrencyCode(?string $currencyCode): string
{
$code = strtoupper(substr(trim((string) ($currencyCode ?? '')), 0, 16));
if ($code !== '') {
return $code;
}
return strtoupper(substr(trim((string) config('lottery.default_currency', 'NPR')), 0, 16));
}
}