feat: 增强抽奖管理功能,支持手动创建、更新和删除期号

- 新增 API 路由和控制器,允许管理员手动创建、更新和删除抽奖期号。
- 更新抽奖调度逻辑,确保在抽奖时间和封盘时间的管理上更加灵活。
- 添加多语言支持的错误信息,提升用户体验。
- 更新测试用例,确保新功能的正确性和稳定性。
This commit is contained in:
2026-05-25 18:00:22 +08:00
parent 770fd8950d
commit c74bec3f64
21 changed files with 855 additions and 51 deletions

View File

@@ -13,6 +13,10 @@ use Illuminate\Database\QueryException;
*/
final class DrawPlannerService
{
public function __construct(
private readonly DrawTimelineBuilder $timeline,
) {}
/** @return array{created: int, buffer_target: int, upcoming: int} */
public function ensureBuffer(?Carbon $now = null): array
{
@@ -39,7 +43,7 @@ final class DrawPlannerService
$row = $last === null
? $this->firstSchedule($tz, $interval, $maxSeq, $nowLocal)
: $this->scheduleAfter($last, $tz, $interval, $maxSeq, $nowLocal);
: $this->scheduleAfter($last, $tz, $interval, $nowLocal);
try {
DB::transaction(function () use ($row, $nowUtc, &$created): void {
@@ -95,27 +99,27 @@ final class DrawPlannerService
/**
* @return array{business_date: string, sequence_no: int, draw_local: Carbon}
*/
private function scheduleAfter(Draw $last, string $tz, int $intervalMinutes, int $maxSeqPerDay, Carbon $nowLocal): array
private function scheduleAfter(Draw $last, string $tz, int $intervalMinutes, Carbon $nowLocal): array
{
$day = Carbon::parse((string) $last->business_date, $tz)->startOfDay();
$seq = (int) $last->sequence_no + 1;
if ($seq > $maxSeqPerDay) {
$day = $day->addDay();
$seq = 1;
$lastDrawLocal = $last->draw_time !== null
? Carbon::parse($last->draw_time)->timezone($tz)
: Carbon::parse((string) $last->business_date, $tz)
->startOfDay()
->addMinutes((int) $last->sequence_no * $intervalMinutes);
$drawLocal = $lastDrawLocal->copy()->addMinutes($intervalMinutes);
while ($drawLocal <= $nowLocal) {
$drawLocal->addMinutes($intervalMinutes);
}
$drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes);
while ($drawLocal <= $nowLocal) {
$seq++;
if ($seq > $maxSeqPerDay) {
$day = $day->addDay();
$seq = 1;
}
$drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes);
}
$businessDate = $drawLocal->format('Y-m-d');
$lastDay = Carbon::parse((string) $last->business_date, $tz)->format('Y-m-d');
$seq = $businessDate === $lastDay
? (int) $last->sequence_no + 1
: 1;
return [
'business_date' => $day->format('Y-m-d'),
'business_date' => $businessDate,
'sequence_no' => $seq,
'draw_local' => $drawLocal->copy()->timezone($tz),
];
@@ -127,37 +131,22 @@ final class DrawPlannerService
*/
private function timelinePayload(array $row, Carbon $nowUtc): array
{
$closeBefore = (int) config('lottery.draw.close_before_draw_seconds', 30);
$bettingWindow = (int) config('lottery.draw.betting_window_seconds', 270);
$drawLocal = $row['draw_local']->copy();
$closeLocal = $drawLocal->copy()->subSeconds($closeBefore);
$startLocal = $closeLocal->copy()->subSeconds($bettingWindow);
$startUtc = $startLocal->copy()->timezone('UTC');
$closeUtc = $closeLocal->copy()->timezone('UTC');
$drawUtc = $drawLocal->copy()->timezone('UTC');
if ($nowUtc < $startUtc) {
$status = DrawStatus::Pending->value;
} elseif ($nowUtc < $closeUtc) {
$status = DrawStatus::Open->value;
} elseif ($nowUtc < $drawUtc) {
$status = DrawStatus::Closing->value;
} else {
$status = DrawStatus::Closed->value;
}
$windows = $this->timeline->windowsFromDrawLocal($row['draw_local']->copy());
$built = $this->timeline->buildFromLocals(
$windows['start_local'],
$windows['close_local'],
$windows['draw_local'],
$nowUtc,
);
return [
'draw_no' => str_replace('-', '', $row['business_date']).'-'.
str_pad((string) $row['sequence_no'], 3, '0', STR_PAD_LEFT),
'draw_no' => $this->timeline->drawNo($row['business_date'], $row['sequence_no']),
'business_date' => $row['business_date'],
'sequence_no' => $row['sequence_no'],
'status' => $status,
'start_time' => $startLocal->copy()->timezone('UTC'),
'close_time' => $closeLocal->copy()->timezone('UTC'),
'draw_time' => $drawLocal->copy()->timezone('UTC'),
'status' => $built['status'],
'start_time' => $built['start_utc'],
'close_time' => $built['close_utc'],
'draw_time' => $built['draw_utc'],
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,