引入 CurrencyResolver,用于在 DrawCurrentController、DrawResultShowController 与 DrawResultsIndexController 中统一处理币种代码解析。 更新 DrawHallSnapshotBuilder 与 DrawResultViewService 的构建方法,新增币种代码参数支持,确保开奖相关功能中的币种处理一致性。 增强 SettingIndexController:新增允许访问的 KV 配置分组校验。 在 OddsStreamService、PlayConfigStreamService 与 RiskCapStreamService 中新增广播功能,用于在玩法目录变更时推送更新通知。 新增测试用例,验证风险限额发布的广播行为。
337 lines
12 KiB
PHP
337 lines
12 KiB
PHP
<?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', 'total_cap_amount', 'locked_amount', 'remaining_amount', 'sold_out_status'])
|
||
->map(fn ($row) => [
|
||
'normalized_number' => (string) $row->normalized_number,
|
||
'total_cap_amount' => (int) $row->total_cap_amount,
|
||
'locked_amount' => (int) $row->locked_amount,
|
||
'remaining_amount' => (int) $row->remaining_amount,
|
||
'sold_out_status' => (int) $row->sold_out_status,
|
||
'is_sold_out' => (int) $row->sold_out_status === 1,
|
||
'usage_ratio' => (int) $row->total_cap_amount > 0
|
||
? round(((int) $row->locked_amount) / (int) $row->total_cap_amount, 6)
|
||
: null,
|
||
])
|
||
->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));
|
||
}
|
||
}
|