Files
lotteryLaravel/app/Services/Draw/DrawHallSnapshotBuilder.php
kang f0e0966a73
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix(lottery): 增加广播任务时效保护并强化命令异常处理
- 为多个广播事件添加 retryUntil 方法,设置派发后 30 秒内必须执行,否则丢弃
- 修改 LotteryDrawTickCommand handle,添加异常捕获及错误日志,保证失败时返回 FAILURE
- 修改 LotteryHallCountdownCommand handle,添加异常捕获及错误日志,超时情况写入警告日志
- 针对 SettlementOrchestrator 和 DrawHallSnapshotBuilder 查询结果添加限制,防止数据量过大
- 调整计划任务 withoutOverlapping 调用,增加 expiresAt 参数避免任务锁死
- 更新 .env.example,完善本地开发与生产环境部署说明及日志配置说明
- 移除 e2e 相关测试代码、配置及依赖,精简项目体积
2026-07-07 13:37:05 +08:00

401 lines
14 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 Illuminate\Support\Facades\Cache;
use App\Services\Jackpot\JackpotSummaryService;
use App\Services\LotterySettings;
/**
* `GET draw/current` 与大厅 WS 快照共用数据结构。
*
* @return array<string, mixed>|null
*/
final class DrawHallSnapshotBuilder
{
private const JACKPOT_CACHE_TTL_SECONDS = 5;
private const RISK_ALERTS_CACHE_TTL_SECONDS = 2;
private const RESULT_ITEMS_CACHE_TTL_SECONDS = 5;
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('status', '!=', DrawStatus::Pending->value)
->orWhere(function ($q2) use ($nowUtc): void {
$q2->where('status', DrawStatus::Pending->value)
->where(function ($q3) use ($nowUtc): void {
$q3->whereNull('close_time')
->orWhere('close_time', '>', $nowUtc);
})
->where(function ($q3) use ($nowUtc): void {
$q3->whereNull('draw_time')
->orWhere('draw_time', '>', $nowUtc);
});
});
})
->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')
->limit(50)
->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 = LotterySettings::drawTimezone();
$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->cachedJackpotSummary($currencyCode),
];
$payload['risk_pool_alerts'] = $this->cachedRiskAlerts((int) $target->id);
if ($this->showsPublishedResults((string) $target->status)) {
$resultItems = $this->cachedPublishedResultItems(
(int) $target->id,
(int) $target->current_result_version,
);
if ($resultItems !== null) {
$payload['result_items'] = $resultItems;
}
$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 LotterySettings::defaultCurrency();
}
/**
* @return array<string, mixed>
*/
private function cachedJackpotSummary(string $currencyCode): array
{
$cacheKey = sprintf('hall_snapshot:jackpot:%s', strtoupper($currencyCode));
/** @var array<string, mixed> */
return Cache::remember($cacheKey, self::JACKPOT_CACHE_TTL_SECONDS, fn (): array => $this->jackpotSummary->summary($currencyCode));
}
/**
* @return list<array{normalized_number: string, status: string}>
*/
private function cachedRiskAlerts(int $drawId): array
{
$cacheKey = sprintf('hall_snapshot:risk_alerts:%d', $drawId);
/** @var list<array{normalized_number: string, status: string}> */
return Cache::remember($cacheKey, self::RISK_ALERTS_CACHE_TTL_SECONDS, function () use ($drawId): array {
return RiskPool::query()
->where('draw_id', $drawId)
->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();
});
}
/**
* @return list<array<string, mixed>>|null
*/
private function cachedPublishedResultItems(int $drawId, int $resultVersion): ?array
{
if ($resultVersion <= 0) {
return null;
}
$cacheKey = sprintf('hall_snapshot:result_items:%d:%d', $drawId, $resultVersion);
/** @var list<array<string, mixed>>|null */
return Cache::remember($cacheKey, self::RESULT_ITEMS_CACHE_TTL_SECONDS, function () use ($drawId, $resultVersion): ?array {
$batchId = DrawResultBatch::query()
->where('draw_id', $drawId)
->where('result_version', $resultVersion)
->where('status', DrawResultBatchStatus::Published->value)
->value('id');
if ($batchId === null) {
return null;
}
return 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();
});
}
}