From c74bec3f6451e5d06467a71b05065edd7da2b17d Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 25 May 2026 18:00:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=8A=BD=E5=A5=96?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=88=9B=E5=BB=BA=E3=80=81=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=92=8C=E5=88=A0=E9=99=A4=E6=9C=9F=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 API 路由和控制器,允许管理员手动创建、更新和删除抽奖期号。 - 更新抽奖调度逻辑,确保在抽奖时间和封盘时间的管理上更加灵活。 - 添加多语言支持的错误信息,提升用户体验。 - 更新测试用例,确保新功能的正确性和稳定性。 --- .env.example | 5 + .../Draw/AdminDrawBatchDestroyController.php | 56 ++++++ .../Admin/Draw/AdminDrawDestroyController.php | 35 ++++ .../Admin/Draw/AdminDrawIndexController.php | 9 +- .../Admin/Draw/AdminDrawStoreController.php | 43 +++++ .../Admin/Draw/AdminDrawUpdateController.php | 47 +++++ app/Http/Requests/Admin/DrawStoreRequest.php | 25 +++ app/Services/Draw/DrawDestroyService.php | 37 ++++ app/Services/Draw/DrawHallSnapshotBuilder.php | 55 +++++- app/Services/Draw/DrawManualCreateService.php | 105 ++++++++++ app/Services/Draw/DrawManualUpdateService.php | 112 +++++++++++ app/Services/Draw/DrawPlannerService.php | 77 ++++---- app/Services/Draw/DrawTimelineBuilder.php | 75 ++++++++ app/Support/AdminAuthorizationRegistry.php | 4 + config/lottery.php | 8 +- lang/en/api.php | 6 + lang/ne/api.php | 6 + lang/zh/api.php | 6 + routes/api/v1/admin/draw.php | 12 ++ tests/Feature/DrawPipelineTest.php | 180 ++++++++++++++++++ tests/Feature/PerformanceAcceptanceTest.php | 3 +- 21 files changed, 855 insertions(+), 51 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawBatchDestroyController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawDestroyController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawStoreController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawUpdateController.php create mode 100644 app/Http/Requests/Admin/DrawStoreRequest.php create mode 100644 app/Services/Draw/DrawDestroyService.php create mode 100644 app/Services/Draw/DrawManualCreateService.php create mode 100644 app/Services/Draw/DrawManualUpdateService.php create mode 100644 app/Services/Draw/DrawTimelineBuilder.php diff --git a/.env.example b/.env.example index 3329438..b09a312 100644 --- a/.env.example +++ b/.env.example @@ -197,6 +197,11 @@ LOTTERY_PLAYER_AUTH_DEV_BYPASS=false # 未来期缓冲条数(draw_time>now 的期数,分钟 tick 会补足);测试可 6–12,生产可 48+ LOTTERY_DRAW_BUFFER_AHEAD=8 +# 期号时刻统一为 UTC(GMT),见 config/lottery.php lottery.draw.timezone 与 docs/01-界面文档.md;勿配置本地时区 +# 开奖间隔(分钟)、下注窗(秒)、封盘提前(秒)见 config/lottery.php,可按需覆盖: +# LOTTERY_DRAW_INTERVAL_MINUTES=5 +# LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270 +# LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30 # 校验主站 JWT 的算法(与签发方一致) LOTTERY_JWT_ALGORITHM=HS256 diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawBatchDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawBatchDestroyController.php new file mode 100644 index 0000000..e9f2db0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawBatchDestroyController.php @@ -0,0 +1,56 @@ +input('draw_ids', []); + + if (!is_array($drawIds) || empty($drawIds)) { + return ApiResponse::error(trans('api.invalid_params'), ErrorCode::ClientHttpError->value, [], 400); + } + + $results = [ + 'success' => [], + 'failed' => [], + ]; + + foreach ($drawIds as $drawId) { + try { + $draw = \App\Models\Draw::findOrFail($drawId); + $this->service->destroy($draw); + $results['success'][] = $drawId; + } catch (\RuntimeException $e) { + $results['failed'][] = [ + 'id' => $drawId, + 'reason' => match ($e->getMessage()) { + 'draw_not_deletable' => trans('api.draw_not_deletable'), + 'draw_has_bets' => trans('api.draw_has_bets'), + 'draw_result_exists' => trans('api.draw_result_exists'), + default => trans('api.client_error'), + }, + ]; + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + $results['failed'][] = [ + 'id' => $drawId, + 'reason' => trans('api.draw_not_found'), + ]; + } + } + + return ApiResponse::success($results); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawDestroyController.php new file mode 100644 index 0000000..873ff7c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawDestroyController.php @@ -0,0 +1,35 @@ +service->destroy($draw); + } catch (\RuntimeException $e) { + $message = match ($e->getMessage()) { + 'draw_not_deletable' => trans('api.draw_not_deletable'), + 'draw_has_bets' => trans('api.draw_has_bets'), + 'draw_result_exists' => trans('api.draw_result_exists'), + default => trans('api.client_error'), + }; + + return ApiResponse::error($message, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 409); + } + + return ApiResponse::success(['deleted' => true]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php index 7cfb852..cda0371 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php @@ -36,7 +36,14 @@ final class AdminDrawIndexController extends Controller /** @var LengthAwarePaginator $paginator */ $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); - return AdminApiList::json($paginator, fn (Draw $row) => $this->row($row)); + return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row), [ + 'schedule' => [ + 'timezone' => (string) config('lottery.draw.timezone', 'UTC'), + 'interval_minutes' => (int) config('lottery.draw.interval_minutes', 5), + 'betting_window_seconds' => (int) config('lottery.draw.betting_window_seconds', 270), + 'close_before_draw_seconds' => (int) config('lottery.draw.close_before_draw_seconds', 30), + ], + ]); } /** @return array */ diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawStoreController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawStoreController.php new file mode 100644 index 0000000..a3cf412 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawStoreController.php @@ -0,0 +1,43 @@ +service->create($request->validated()); + } catch (\RuntimeException $e) { + $message = match ($e->getMessage()) { + 'draw_no_exists' => trans('api.draw_no_exists'), + 'draw_timeline_invalid' => trans('api.draw_timeline_invalid'), + default => trans('api.client_error'), + }; + + return ApiResponse::error($message, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 409); + } + + return ApiResponse::success([ + 'id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'business_date' => (string) $draw->business_date, + 'sequence_no' => (int) $draw->sequence_no, + 'status' => $draw->status, + 'start_time' => $draw->start_time?->toIso8601String(), + 'close_time' => $draw->close_time?->toIso8601String(), + 'draw_time' => $draw->draw_time?->toIso8601String(), + ])->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawUpdateController.php new file mode 100644 index 0000000..80d5ed3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawUpdateController.php @@ -0,0 +1,47 @@ +service->update($draw, $request->validated()); + } catch (\RuntimeException $e) { + $message = match ($e->getMessage()) { + 'draw_no_exists' => trans('api.draw_no_exists'), + 'draw_timeline_invalid' => trans('api.draw_timeline_invalid'), + 'draw_not_editable' => trans('api.draw_not_editable'), + 'draw_has_bets' => trans('api.draw_has_bets'), + 'draw_result_exists' => trans('api.draw_result_exists'), + default => trans('api.client_error'), + }; + + return ApiResponse::error($message, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 409); + } + + return ApiResponse::success([ + 'id' => (int) $updated->id, + 'draw_no' => $updated->draw_no, + 'business_date' => (string) $updated->business_date, + 'sequence_no' => (int) $updated->sequence_no, + 'status' => $updated->status, + 'start_time' => $updated->start_time?->toIso8601String(), + 'close_time' => $updated->close_time?->toIso8601String(), + 'draw_time' => $updated->draw_time?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Requests/Admin/DrawStoreRequest.php b/app/Http/Requests/Admin/DrawStoreRequest.php new file mode 100644 index 0000000..965f4c0 --- /dev/null +++ b/app/Http/Requests/Admin/DrawStoreRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:32'], + 'start_time' => ['nullable', 'string', 'max:32'], + 'close_time' => ['nullable', 'string', 'max:32'], + 'draw_no' => ['nullable', 'string', 'max:32', 'regex:/^[0-9]{8}-[0-9]{3}$/'], + 'business_date' => ['nullable', 'date_format:Y-m-d'], + 'sequence_no' => ['nullable', 'integer', 'min:1', 'max:999'], + ]; + } +} diff --git a/app/Services/Draw/DrawDestroyService.php b/app/Services/Draw/DrawDestroyService.php new file mode 100644 index 0000000..ac087a2 --- /dev/null +++ b/app/Services/Draw/DrawDestroyService.php @@ -0,0 +1,37 @@ +whereKey($draw->id)->lockForUpdate()->firstOrFail(); + + if ($locked->status !== DrawStatus::Pending->value) { + throw new \RuntimeException('draw_not_deletable'); + } + + if ($locked->resultBatches()->exists()) { + throw new \RuntimeException('draw_result_exists'); + } + + $betTotal = (int) TicketOrder::query()->where('draw_id', $locked->id)->sum('total_actual_deduct'); + if ($betTotal > 0) { + throw new \RuntimeException('draw_has_bets'); + } + + $locked->delete(); + }); + } +} diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index c048a69..eded5d3 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -94,12 +94,38 @@ final class DrawHallSnapshotBuilder 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, ]) - ->orderBy('draw_time') + ->orderByDesc('draw_time') ->first(); if ($chronological !== null && $this->isCooldownExpired($chronological, $nowUtc)) { @@ -120,6 +146,23 @@ final class DrawHallSnapshotBuilder 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 @@ -167,6 +210,11 @@ final class DrawHallSnapshotBuilder ? 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 @@ -180,7 +228,11 @@ final class DrawHallSnapshotBuilder $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') @@ -191,6 +243,7 @@ final class DrawHallSnapshotBuilder '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, diff --git a/app/Services/Draw/DrawManualCreateService.php b/app/Services/Draw/DrawManualCreateService.php new file mode 100644 index 0000000..cd41905 --- /dev/null +++ b/app/Services/Draw/DrawManualCreateService.php @@ -0,0 +1,105 @@ +utc(); + + $drawLocal = $this->parseInTimezone((string) $input['draw_time'], $tz); + $startLocal = isset($input['start_time']) && $input['start_time'] !== null && $input['start_time'] !== '' + ? $this->parseInTimezone((string) $input['start_time'], $tz) + : null; + $closeLocal = isset($input['close_time']) && $input['close_time'] !== null && $input['close_time'] !== '' + ? $this->parseInTimezone((string) $input['close_time'], $tz) + : null; + + if ($startLocal === null || $closeLocal === null) { + $defaults = $this->timeline->windowsFromDrawLocal($drawLocal); + $startLocal ??= $defaults['start_local']; + $closeLocal ??= $defaults['close_local']; + } + + if (! $startLocal->lt($closeLocal) || ! $closeLocal->lt($drawLocal)) { + throw new \RuntimeException('draw_timeline_invalid'); + } + + $businessDate = isset($input['business_date']) && $input['business_date'] !== '' + ? (string) $input['business_date'] + : $drawLocal->format('Y-m-d'); + + $sequenceNo = isset($input['sequence_no']) && $input['sequence_no'] !== null + ? max(1, (int) $input['sequence_no']) + : $this->nextSequenceForDate($businessDate); + + $drawNo = isset($input['draw_no']) && trim((string) $input['draw_no']) !== '' + ? trim((string) $input['draw_no']) + : $this->timeline->drawNo($businessDate, $sequenceNo); + + if (Draw::query()->where('draw_no', $drawNo)->exists()) { + throw new \RuntimeException('draw_no_exists'); + } + + $built = $this->timeline->buildFromLocals($startLocal, $closeLocal, $drawLocal, $nowUtc); + + return DB::transaction(function () use ( + $drawNo, + $businessDate, + $sequenceNo, + $built, + ): Draw { + return Draw::query()->create([ + 'draw_no' => $drawNo, + 'business_date' => $businessDate, + 'sequence_no' => $sequenceNo, + '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, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + }); + } + + private function parseInTimezone(string $value, string $tz): Carbon + { + return Carbon::parse($value, $tz); + } + + private function nextSequenceForDate(string $businessDate): int + { + $max = (int) Draw::query() + ->where('business_date', $businessDate) + ->max('sequence_no'); + + return $max + 1; + } +} diff --git a/app/Services/Draw/DrawManualUpdateService.php b/app/Services/Draw/DrawManualUpdateService.php new file mode 100644 index 0000000..a98814d --- /dev/null +++ b/app/Services/Draw/DrawManualUpdateService.php @@ -0,0 +1,112 @@ +utc(); + + return DB::transaction(function () use ($draw, $input, $tz, $nowUtc): Draw { + /** @var Draw $locked */ + $locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail(); + $this->assertEditable($locked); + + $drawLocal = Carbon::parse((string) $input['draw_time'], $tz); + $startLocal = isset($input['start_time']) && $input['start_time'] !== null && $input['start_time'] !== '' + ? Carbon::parse((string) $input['start_time'], $tz) + : null; + $closeLocal = isset($input['close_time']) && $input['close_time'] !== null && $input['close_time'] !== '' + ? Carbon::parse((string) $input['close_time'], $tz) + : null; + + if ($startLocal === null || $closeLocal === null) { + $defaults = $this->timeline->windowsFromDrawLocal($drawLocal); + $startLocal ??= $defaults['start_local']; + $closeLocal ??= $defaults['close_local']; + } + + if (! $startLocal->lt($closeLocal) || ! $closeLocal->lt($drawLocal)) { + throw new \RuntimeException('draw_timeline_invalid'); + } + + $businessDate = isset($input['business_date']) && $input['business_date'] !== '' + ? (string) $input['business_date'] + : $drawLocal->format('Y-m-d'); + + $sequenceNo = isset($input['sequence_no']) && $input['sequence_no'] !== null + ? max(1, (int) $input['sequence_no']) + : (int) $locked->sequence_no; + + $drawNo = isset($input['draw_no']) && trim((string) $input['draw_no']) !== '' + ? trim((string) $input['draw_no']) + : (string) $locked->draw_no; + + if ( + Draw::query() + ->where('draw_no', $drawNo) + ->where('id', '!=', $locked->id) + ->exists() + ) { + throw new \RuntimeException('draw_no_exists'); + } + + $built = $this->timeline->buildFromLocals($startLocal, $closeLocal, $drawLocal, $nowUtc); + + $locked->forceFill([ + 'draw_no' => $drawNo, + 'business_date' => $businessDate, + 'sequence_no' => $sequenceNo, + 'status' => $built['status'], + 'start_time' => $built['start_utc'], + 'close_time' => $built['close_utc'], + 'draw_time' => $built['draw_utc'], + ])->save(); + + return $locked->refresh(); + }); + } + + private function assertEditable(Draw $draw): void + { + if ($draw->resultBatches()->exists()) { + throw new \RuntimeException('draw_result_exists'); + } + + if (! in_array($draw->status, [DrawStatus::Pending->value, DrawStatus::Open->value], true)) { + throw new \RuntimeException('draw_not_editable'); + } + + if ($draw->status === DrawStatus::Open->value) { + $betTotal = (int) TicketOrder::query()->where('draw_id', $draw->id)->sum('total_actual_deduct'); + if ($betTotal > 0) { + throw new \RuntimeException('draw_has_bets'); + } + } + } +} diff --git a/app/Services/Draw/DrawPlannerService.php b/app/Services/Draw/DrawPlannerService.php index 05f9ef2..572cff4 100644 --- a/app/Services/Draw/DrawPlannerService.php +++ b/app/Services/Draw/DrawPlannerService.php @@ -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, diff --git a/app/Services/Draw/DrawTimelineBuilder.php b/app/Services/Draw/DrawTimelineBuilder.php new file mode 100644 index 0000000..9581d3c --- /dev/null +++ b/app/Services/Draw/DrawTimelineBuilder.php @@ -0,0 +1,75 @@ +copy()->subSeconds($this->closeBeforeDrawSeconds()); + $startLocal = $closeLocal->copy()->subSeconds($this->bettingWindowSeconds()); + + return [ + 'start_local' => $startLocal, + 'close_local' => $closeLocal, + 'draw_local' => $drawLocal->copy(), + ]; + } + + /** + * @return array{start_utc: Carbon, close_utc: Carbon, draw_utc: Carbon, status: string} + */ + public function buildFromLocals(Carbon $startLocal, Carbon $closeLocal, Carbon $drawLocal, Carbon $nowUtc): array + { + $startUtc = $startLocal->copy()->timezone('UTC'); + $closeUtc = $closeLocal->copy()->timezone('UTC'); + $drawUtc = $drawLocal->copy()->timezone('UTC'); + + return [ + 'start_utc' => $startUtc, + 'close_utc' => $closeUtc, + 'draw_utc' => $drawUtc, + 'status' => $this->statusForTimeline($nowUtc, $startUtc, $closeUtc, $drawUtc), + ]; + } + + public function statusForTimeline(Carbon $nowUtc, Carbon $startUtc, Carbon $closeUtc, Carbon $drawUtc): string + { + if ($nowUtc < $startUtc) { + return DrawStatus::Pending->value; + } + if ($nowUtc < $closeUtc) { + return DrawStatus::Open->value; + } + if ($nowUtc < $drawUtc) { + return DrawStatus::Closing->value; + } + + return DrawStatus::Closed->value; + } + + public function drawNo(string $businessDate, int $sequenceNo): string + { + return str_replace('-', '', $businessDate).'-'. + str_pad((string) $sequenceNo, 3, '0', STR_PAD_LEFT); + } +} diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 5d18c0f..42d35f7 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -398,6 +398,10 @@ final class AdminAuthorizationRegistry ['code' => 'admin.draws.result-batches.publish', 'module_code' => 'draw', 'name' => '发布开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.reopen', 'module_code' => 'draw', 'name' => '重开开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/reopen', 'route_name' => 'api.v1.admin.draws.reopen', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_reopen.manage']], ['code' => 'admin.draws.generate-plan', 'module_code' => 'draw', 'name' => '生成开奖计划', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/generate-plan', 'route_name' => 'api.v1.admin.draws.generate-plan', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], + ['code' => 'admin.draws.store', 'module_code' => 'draw', 'name' => '手动创建期号', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], + ['code' => 'admin.draws.update', 'module_code' => 'draw', 'name' => '编辑期号计划', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], + ['code' => 'admin.draws.destroy', 'module_code' => 'draw', 'name' => '删除期号', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], + ['code' => 'admin.draws.batch-destroy', 'module_code' => 'draw', 'name' => '批量删除期号', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/batch-destroy', 'route_name' => 'api.v1.admin.draws.batch-destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.manual-close', 'module_code' => 'draw', 'name' => '人工封盘', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/manual-close', 'route_name' => 'api.v1.admin.draws.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.risk-pools.manual-close', 'module_code' => 'risk', 'name' => '人工关闭风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/manual-close', 'route_name' => 'api.v1.admin.draws.risk-pools.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']], ['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']], diff --git a/config/lottery.php b/config/lottery.php index 2305e80..7b7e50a 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -75,12 +75,12 @@ return [ ], /* - | 期号调度(GMT/业务日时区):生成计划、封盘与开奖时间点。 - | 与 PRD 「期号生成 / 封盘 / 开奖调度」链路一致;RNG 详见 DrawRngRunner。 + | 期号调度:PRD/界面文档约定为服务器时区 GMT(即 UTC)。 + | 生成计划、封盘判定、API ISO 时刻、前后台 YYYY-MM-DD HH:mm:ss 展示均按 UTC 解释,勿改为本地时区。 */ 'draw' => [ - /** 盘面「业务日」切分与应用展示用 */ - 'timezone' => env('LOTTERY_DRAW_TIMEZONE', 'UTC'), + /** 期号「业务日」切分与计划时刻(固定 UTC,与 {@see docs/01-界面文档.md} 一致) */ + 'timezone' => 'UTC', /** 开奖时间间隔(分钟),整日从 00:00 起排槽 */ 'interval_minutes' => max(1, min(1440, (int) env('LOTTERY_DRAW_INTERVAL_MINUTES', 5))), /** 下注开放时长(秒):start_time = close_time - betting_window_seconds */ diff --git a/lang/en/api.php b/lang/en/api.php index ecd90bc..95cc5fc 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -3,6 +3,12 @@ return [ 'validation_failed' => 'The given data was invalid.', 'client_error' => 'This request could not be completed.', + 'draw_no_exists' => 'Draw number already exists. Use another draw number or sequence.', + 'draw_timeline_invalid' => 'Start time must be before close time, and close time must be before draw time.', + 'draw_not_editable' => 'Only pending draws, or open draws with no bets, can be edited.', + 'draw_not_deletable' => 'Only pending draws with no bets can be deleted.', + 'draw_has_bets' => 'This draw already has bets and cannot be edited or deleted.', + 'draw_result_exists' => 'This draw already has result data and cannot be edited or deleted.', 'settlement_not_approved' => 'Settlement batch is not approved for payout (both status and review_status must be approved).', 'not_found' => 'The requested resource was not found.', 'too_many_requests' => 'Too many requests. Please try again later.', diff --git a/lang/ne/api.php b/lang/ne/api.php index 3308792..5793c73 100644 --- a/lang/ne/api.php +++ b/lang/ne/api.php @@ -3,6 +3,12 @@ return [ 'validation_failed' => 'दिइएको डाटा अमान्य छ।', 'client_error' => 'यो अनुरोध पूरा गर्न सकिएन।', + 'draw_no_exists' => 'यो ड्र नम्बर पहिले नै छ। अर्को ड्र नम्बर वा क्रम प्रयोग गर्नुहोस्।', + 'draw_timeline_invalid' => 'सुरु समय बन्द समय भन्दा अघि, बन्द समय ड्र समय भन्दा अघि हुनुपर्छ।', + 'draw_not_editable' => 'केवल pending वा बेट नभएको open ड्र सम्पादन गर्न सकिन्छ।', + 'draw_not_deletable' => 'केवल pending र बेट नभएको ड्र मेटाउन सकिन्छ।', + 'draw_has_bets' => 'यस ड्रमा पहिले नै बेट छ, सम्पादन/मेटाउन मिल्दैन।', + 'draw_result_exists' => 'यस ड्रमा नतिजा छ, सम्पादन/मेटाउन मिल्दैन।', 'settlement_not_approved' => 'सेटलमेन्ट ब्याच पेमेन्टका लागि स्वीकृत छैन (status र review_status दुवै approved हुनुपर्छ)।', 'not_found' => 'अनुरोध गरिएको स्रोत फेला परेन।', 'too_many_requests' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।', diff --git a/lang/zh/api.php b/lang/zh/api.php index d0c583f..da12b85 100644 --- a/lang/zh/api.php +++ b/lang/zh/api.php @@ -3,6 +3,12 @@ return [ 'validation_failed' => '请求参数校验未通过。', 'client_error' => '请求无法完成。', + 'draw_no_exists' => '期号已存在,请更换期号或流水号。', + 'draw_timeline_invalid' => '开始时间须早于封盘时间,封盘时间须早于开奖时间。', + 'draw_not_editable' => '仅「未开始」或「可下注且无注单」的期号可编辑时间。', + 'draw_not_deletable' => '仅「未开始」且无注单的期号可删除。', + 'draw_has_bets' => '该期已有玩家注单,不能编辑或删除。', + 'draw_result_exists' => '该期已有开奖结果,不能编辑或删除。', 'settlement_not_approved' => '结算批次尚未审核通过,无法派彩(需 status 与 review_status 均为 approved)。', 'not_found' => '请求的资源不存在。', 'too_many_requests' => '请求过于频繁,请稍后再试。', diff --git a/routes/api/v1/admin/draw.php b/routes/api/v1/admin/draw.php index 04b9979..a39a819 100644 --- a/routes/api/v1/admin/draw.php +++ b/routes/api/v1/admin/draw.php @@ -6,6 +6,10 @@ use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawReopenController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawCancelController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawRngRunController; +use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawStoreController; +use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawUpdateController; +use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawDestroyController; +use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawBatchDestroyController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawPlanGenerateController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawSettlementRunController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawManualCloseController; @@ -60,6 +64,14 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.draws.reopen'); Route::post('draws/generate-plan', DrawPlanGenerateController::class) ->name('api.v1.admin.draws.generate-plan'); + Route::post('draws', AdminDrawStoreController::class) + ->name('api.v1.admin.draws.store'); + Route::put('draws/{draw}', AdminDrawUpdateController::class) + ->name('api.v1.admin.draws.update'); + Route::delete('draws/{draw}', AdminDrawDestroyController::class) + ->name('api.v1.admin.draws.destroy'); + Route::post('draws/batch-destroy', AdminDrawBatchDestroyController::class) + ->name('api.v1.admin.draws.batch-destroy'); Route::post('draws/{draw}/manual-close', DrawManualCloseController::class) ->name('api.v1.admin.draws.manual-close'); Route::post('draws/{draw}/risk-pools/{number_4d}/manual-close', [AdminRiskPoolManualStatusController::class, 'close']) diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index 07b5b51..73d8389 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -72,6 +72,141 @@ test('admin can batch generate draw schedule buffer', function (): void { Carbon::setTestNow(); }); +test('draw planner schedules after last draw_time not midnight slot', function (): void { + config([ + 'lottery.draw.interval_minutes' => 5, + 'lottery.draw.buffer_draws_ahead' => 2, + ]); + + $fixed = Carbon::parse('2026-05-25 11:00:00', 'UTC'); + $lastId = Draw::query()->create([ + 'draw_no' => '20260525-120', + 'business_date' => '2026-05-25', + 'sequence_no' => 120, + 'status' => DrawStatus::Settled->value, + 'start_time' => Carbon::parse('2026-05-25 11:54:30', 'UTC'), + 'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'), + 'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ])->id; + + app(DrawPlannerService::class)->ensureBuffer($fixed); + + $next = Draw::query()->where('id', '>', $lastId)->orderBy('draw_time')->first(); + expect($next)->not->toBeNull(); + expect($next->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:05:00'); + expect($next->sequence_no)->toBe(121); +}); + +test('admin can manually create draw with custom timeline', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC')); + + $admin = AdminUser::query()->create([ + 'username' => 'draw_create_admin', + 'name' => 'Draw Create Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/draws', [ + 'draw_time' => '2026-05-25 12:00:00', + 'start_time' => '2026-05-25 11:55:00', + 'close_time' => '2026-05-25 11:59:30', + 'draw_no' => '20260525-901', + ]) + ->assertCreated() + ->assertJsonPath('data.draw_no', '20260525-901') + ->assertJsonPath('data.status', DrawStatus::Pending->value); + + $draw = Draw::query()->where('draw_no', '20260525-901')->first(); + expect($draw)->not->toBeNull(); + expect($draw->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:00:00'); + + Carbon::setTestNow(); +}); + +test('admin can update pending draw timeline', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC')); + + $admin = AdminUser::query()->create([ + 'username' => 'draw_update_admin', + 'name' => 'Draw Update Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $draw = Draw::query()->create([ + 'draw_no' => '20260525-902', + 'business_date' => '2026-05-25', + 'sequence_no' => 902, + 'status' => DrawStatus::Pending->value, + 'start_time' => Carbon::parse('2026-05-25 11:55:00', 'UTC'), + 'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'), + 'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/draws/'.$draw->id, [ + 'draw_time' => '2026-05-25 13:00:00', + 'start_time' => '2026-05-25 12:55:00', + 'close_time' => '2026-05-25 12:59:30', + ]) + ->assertOk() + ->assertJsonPath('data.draw_time', fn ($v) => str_contains((string) $v, '13:00')); + + Carbon::setTestNow(); +}); + +test('admin can destroy pending draw without bets', function (): void { + $admin = AdminUser::query()->create([ + 'username' => 'draw_destroy_admin', + 'name' => 'Draw Destroy Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $draw = Draw::query()->create([ + 'draw_no' => '20260525-903', + 'business_date' => '2026-05-25', + 'sequence_no' => 903, + 'status' => DrawStatus::Pending->value, + 'start_time' => now()->addHour(), + 'close_time' => now()->addHours(2), + 'draw_time' => now()->addHours(3), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/draws/'.$draw->id) + ->assertOk() + ->assertJsonPath('data.deleted', true); + + expect(Draw::query()->find($draw->id))->toBeNull(); +}); + test('admin can manually close open draw', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:10:00', 'UTC')); @@ -617,6 +752,7 @@ test('GET draw current returns open draw with seconds to close', function (): vo $this->getJson('/api/v1/draw/current') ->assertOk() + ->assertJsonPath('data.data.schedule_timezone', 'UTC') ->assertJsonPath('data.data.draw_no', '20260509-300') ->assertJsonPath('data.data.status', DrawStatus::Open->value) ->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30) @@ -771,6 +907,50 @@ test('lottery hall-countdown dispatches draw.countdown when using reverb connect ); }); +test('hall snapshot skips stale pending draw and picks next upcoming row', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-25 18:00:00', 'UTC')); + + Draw::query()->create([ + 'draw_no' => '20260525-999', + 'business_date' => '2026-05-25', + 'sequence_no' => 999, + 'status' => DrawStatus::Pending->value, + 'start_time' => Carbon::parse('2026-05-25 17:32:00', 'UTC'), + 'close_time' => Carbon::parse('2026-05-25 17:36:30', 'UTC'), + 'draw_time' => Carbon::parse('2026-05-25 17:37:00', 'UTC'), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + Draw::query()->create([ + 'draw_no' => '20260525-1006', + 'business_date' => '2026-05-25', + 'sequence_no' => 1006, + 'status' => DrawStatus::Pending->value, + 'start_time' => Carbon::parse('2026-05-25 18:07:00', 'UTC'), + 'close_time' => Carbon::parse('2026-05-25 18:11:30', 'UTC'), + 'draw_time' => Carbon::parse('2026-05-25 18:12:00', 'UTC'), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc()); + expect($target)->not->toBeNull() + ->and($target->draw_no)->toBe('20260525-1006'); + + $payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc()); + expect($payload['draw_no'])->toBe('20260525-1006') + ->and($payload['seconds_to_start'])->toBeGreaterThan(0); + + Carbon::setTestNow(); +}); + test('hall snapshot switches to next bettable draw when cooldown ended', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC')); diff --git a/tests/Feature/PerformanceAcceptanceTest.php b/tests/Feature/PerformanceAcceptanceTest.php index fe92830..f1fc520 100644 --- a/tests/Feature/PerformanceAcceptanceTest.php +++ b/tests/Feature/PerformanceAcceptanceTest.php @@ -34,7 +34,8 @@ test('draw planner schedules five minute draw_time gaps', function (): void { ]); $fixed = Carbon::parse('2026-05-25 00:00:00', 'UTC'); - app(DrawPlannerService::class)->ensureBuffer($fixed); + $report = app(DrawPlannerService::class)->ensureBuffer($fixed); + expect($report['created'])->toBe(12); $times = Draw::query() ->whereNotNull('draw_time')