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

@@ -197,6 +197,11 @@ LOTTERY_PLAYER_AUTH_DEV_BYPASS=false
# 未来期缓冲条数draw_time>now 的期数,分钟 tick 会补足);测试可 612生产可 48+
LOTTERY_DRAW_BUFFER_AHEAD=8
# 期号时刻统一为 UTCGMT见 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

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\Draw\DrawDestroyService;
final class AdminDrawBatchDestroyController extends Controller
{
public function __construct(
private readonly DrawDestroyService $service,
) {}
public function __invoke(Request $request): JsonResponse
{
$drawIds = $request->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);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Models\Draw;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Draw\DrawDestroyService;
final class AdminDrawDestroyController extends Controller
{
public function __construct(
private readonly DrawDestroyService $service,
) {}
public function __invoke(Draw $draw): JsonResponse
{
try {
$this->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]);
}
}

View File

@@ -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<string, mixed> */

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Draw\DrawManualCreateService;
use App\Http\Requests\Admin\DrawStoreRequest;
final class AdminDrawStoreController extends Controller
{
public function __construct(
private readonly DrawManualCreateService $service,
) {}
public function __invoke(DrawStoreRequest $request): JsonResponse
{
try {
$draw = $this->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);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Models\Draw;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\DrawStoreRequest;
use App\Services\Draw\DrawManualUpdateService;
final class AdminDrawUpdateController extends Controller
{
public function __construct(
private readonly DrawManualUpdateService $service,
) {}
public function __invoke(DrawStoreRequest $request, Draw $draw): JsonResponse
{
try {
$updated = $this->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(),
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class DrawStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'draw_time' => ['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'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Services\Draw;
use App\Models\Draw;
use App\Models\TicketOrder;
use App\Lottery\DrawStatus;
use Illuminate\Support\Facades\DB;
/**
* 物理删除误建的未开始期号(无注单、无开奖结果)。
*/
final class DrawDestroyService
{
public function destroy(Draw $draw): void
{
DB::transaction(function () use ($draw): void {
/** @var Draw $locked */
$locked = Draw::query()->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();
});
}
}

View File

@@ -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,

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use Illuminate\Support\Facades\DB;
/**
* 管理员手动创建期号(可指定开奖/封盘/开始时间)。
*/
final class DrawManualCreateService
{
public function __construct(
private readonly DrawTimelineBuilder $timeline,
) {}
/**
* @param array{
* draw_time: string,
* start_time?: string|null,
* close_time?: string|null,
* draw_no?: string|null,
* business_date?: string|null,
* sequence_no?: int|null,
* } $input
*/
public function create(array $input, ?Carbon $now = null): Draw
{
$tz = (string) config('lottery.draw.timezone', 'UTC');
$nowUtc = ($now ?? Carbon::now())->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;
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\TicketOrder;
use App\Lottery\DrawStatus;
use Illuminate\Support\Facades\DB;
/**
* 管理员修改未开奖期号的时间轴(仅 pending / 无注单的 open
*/
final class DrawManualUpdateService
{
public function __construct(
private readonly DrawTimelineBuilder $timeline,
) {}
/**
* @param array{
* draw_time: string,
* start_time?: string|null,
* close_time?: string|null,
* draw_no?: string|null,
* business_date?: string|null,
* sequence_no?: int|null,
* } $input
*/
public function update(Draw $draw, array $input, ?Carbon $now = null): Draw
{
$tz = (string) config('lottery.draw.timezone', 'UTC');
$nowUtc = ($now ?? Carbon::now())->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');
}
}
}
}

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,

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Services\Draw;
use Carbon\Carbon;
use App\Lottery\DrawStatus;
/**
* 由开奖时刻推导下注窗口、封盘时刻与期号初始状态。
*/
final class DrawTimelineBuilder
{
public function closeBeforeDrawSeconds(): int
{
return (int) config('lottery.draw.close_before_draw_seconds', 30);
}
public function bettingWindowSeconds(): int
{
return (int) config('lottery.draw.betting_window_seconds', 270);
}
/**
* @return array{start_local: Carbon, close_local: Carbon, draw_local: Carbon}
*/
public function windowsFromDrawLocal(Carbon $drawLocal): array
{
$closeLocal = $drawLocal->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);
}
}

View File

@@ -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']],

View File

@@ -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 */

View File

@@ -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.',

View File

@@ -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' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।',

View File

@@ -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' => '请求过于频繁,请稍后再试。',

View File

@@ -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'])

View File

@@ -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'));

View File

@@ -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')