Files
lotteryLaravel/app/Services/Draw/DrawHallSnapshotBuilder.php
kang a9d0f39a9c feat: 增强开奖与设置控制器的币种支持功能
引入 CurrencyResolver,用于在 DrawCurrentController、DrawResultShowController 与 DrawResultsIndexController 中统一处理币种代码解析。
更新 DrawHallSnapshotBuilder 与 DrawResultViewService 的构建方法,新增币种代码参数支持,确保开奖相关功能中的币种处理一致性。
增强 SettingIndexController:新增允许访问的 KV 配置分组校验。
在 OddsStreamService、PlayConfigStreamService 与 RiskCapStreamService 中新增广播功能,用于在玩法目录变更时推送更新通知。
新增测试用例,验证风险限额发布的广播行为。
2026-05-27 09:57:39 +08:00

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