utc(); $tz = (string) config('lottery.draw.timezone', 'UTC'); $interval = (int) config('lottery.draw.interval_minutes', 5); $buffer = (int) config('lottery.draw.buffer_draws_ahead', 8); $maxSeq = intdiv(24 * 60, $interval); $upcoming = Draw::query() ->where('draw_time', '>', $nowUtc) ->where('status', '!=', DrawStatus::Cancelled->value) ->count(); $created = 0; $guard = 0; while ($upcoming < $buffer && $guard < 10_000) { $guard++; $nowLocal = $nowUtc->copy()->timezone($tz); $last = Draw::query() ->orderByDesc('business_date') ->orderByDesc('sequence_no') ->first(); $row = $last === null ? $this->firstSchedule($tz, $interval, $maxSeq, $nowLocal) : $this->scheduleAfter($last, $tz, $interval, $maxSeq, $nowLocal); try { DB::transaction(function () use ($row, $nowUtc, &$created): void { Draw::query()->create($this->timelinePayload($row, $nowUtc)); $created++; }); $upcoming++; } catch (QueryException $e) { if (($e->errorInfo[1] ?? null) === 19 || str_contains($e->getMessage(), 'unique')) { /** 并发或重试:下一循环用新的 last 行 */ continue; } throw $e; } } return [ 'created' => $created, 'buffer_target' => $buffer, 'upcoming' => $upcoming, ]; } /** * @return array{business_date: string, sequence_no: int, draw_local: Carbon} */ private function firstSchedule(string $tz, int $intervalMinutes, int $maxSeqPerDay, Carbon $nowLocal): array { $day = $nowLocal->copy()->startOfDay(); $seq = 1; $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); while ($drawLocal <= $nowLocal && $seq <= $maxSeqPerDay) { $seq++; $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); } if ($seq > $maxSeqPerDay) { $day = $day->addDay(); $seq = 1; $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); while ($drawLocal <= $nowLocal && $seq <= $maxSeqPerDay) { $seq++; $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); } } return [ 'business_date' => $day->format('Y-m-d'), 'sequence_no' => $seq, 'draw_local' => $drawLocal->copy()->timezone($tz), ]; } /** * @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 { $day = Carbon::parse((string) $last->business_date, $tz)->startOfDay(); $seq = (int) $last->sequence_no + 1; if ($seq > $maxSeqPerDay) { $day = $day->addDay(); $seq = 1; } $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); while ($drawLocal <= $nowLocal) { $seq++; if ($seq > $maxSeqPerDay) { $day = $day->addDay(); $seq = 1; } $drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes); } return [ 'business_date' => $day->format('Y-m-d'), 'sequence_no' => $seq, 'draw_local' => $drawLocal->copy()->timezone($tz), ]; } /** * @param array{business_date: string, sequence_no: int, draw_local: Carbon} $row * @return array */ 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; } return [ 'draw_no' => str_replace('-', '', $row['business_date']).'-'. str_pad((string) $row['sequence_no'], 3, '0', STR_PAD_LEFT), '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'), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]; } }