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

@@ -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;
}
}