Files
lotteryLaravel/app/Services/Draw/DrawHallSnapshotBuilder.php
kang c74bec3f64 feat: 增强抽奖管理功能,支持手动创建、更新和删除期号
- 新增 API 路由和控制器,允许管理员手动创建、更新和删除抽奖期号。
- 更新抽奖调度逻辑,确保在抽奖时间和封盘时间的管理上更加灵活。
- 添加多语言支持的错误信息,提升用户体验。
- 更新测试用例,确保新功能的正确性和稳定性。
2026-05-25 18:00:22 +08:00

316 lines
11 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;
}
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): ?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;
$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' => $this->jackpotSummary->summary('NPR'),
];
$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;
}
}