feat: 更新玩法配置管理,简化字段并增强功能
- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。 - 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。 - 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
@@ -30,6 +30,19 @@ final class DrawHallSnapshotBuilder
|
||||
public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string
|
||||
{
|
||||
$db = (string) $target->status;
|
||||
|
||||
if ($db === DrawStatus::Pending->value) {
|
||||
$startUtc = $target->start_time;
|
||||
if ($startUtc instanceof Carbon && $startUtc <= $nowUtc) {
|
||||
$closeUtc = $target->close_time;
|
||||
if ($closeUtc === null || $closeUtc > $nowUtc) {
|
||||
$db = DrawStatus::Open->value;
|
||||
}
|
||||
} else {
|
||||
return $db;
|
||||
}
|
||||
}
|
||||
|
||||
if ($db !== DrawStatus::Open->value) {
|
||||
return $db;
|
||||
}
|
||||
@@ -62,7 +75,14 @@ final class DrawHallSnapshotBuilder
|
||||
$nowUtc = ($nowUtc ?? Carbon::now())->utc();
|
||||
|
||||
$bettingOpen = Draw::query()
|
||||
->where('status', DrawStatus::Open->value)
|
||||
->where(function ($q) use ($nowUtc): void {
|
||||
$q->where('status', DrawStatus::Open->value)
|
||||
->orWhere(function ($q2) use ($nowUtc): void {
|
||||
$q2->where('status', DrawStatus::Pending->value)
|
||||
->whereNotNull('start_time')
|
||||
->where('start_time', '<=', $nowUtc);
|
||||
});
|
||||
})
|
||||
->where(function ($q) use ($nowUtc): void {
|
||||
$q->whereNull('close_time')
|
||||
->orWhere('close_time', '>', $nowUtc);
|
||||
@@ -70,6 +90,10 @@ final class DrawHallSnapshotBuilder
|
||||
->orderBy('draw_time')
|
||||
->first();
|
||||
|
||||
if ($bettingOpen !== null) {
|
||||
return $bettingOpen;
|
||||
}
|
||||
|
||||
$chronological = Draw::query()
|
||||
->whereNotIn('status', [
|
||||
DrawStatus::Settled->value,
|
||||
@@ -78,7 +102,29 @@ final class DrawHallSnapshotBuilder
|
||||
->orderBy('draw_time')
|
||||
->first();
|
||||
|
||||
return $bettingOpen ?? $chronological;
|
||||
if ($chronological !== null && $this->isCooldownExpired($chronological, $nowUtc)) {
|
||||
$next = Draw::query()
|
||||
->whereNotIn('status', [
|
||||
DrawStatus::Settled->value,
|
||||
DrawStatus::Cancelled->value,
|
||||
])
|
||||
->where('draw_time', '>', $chronological->draw_time)
|
||||
->orderBy('draw_time')
|
||||
->first();
|
||||
|
||||
if ($next !== null) {
|
||||
return $next;
|
||||
}
|
||||
}
|
||||
|
||||
return $chronological;
|
||||
}
|
||||
|
||||
private function isCooldownExpired(Draw $draw, Carbon $nowUtc): bool
|
||||
{
|
||||
return (string) $draw->status === DrawStatus::Cooldown->value
|
||||
&& $draw->cooling_end_time instanceof Carbon
|
||||
&& $draw->cooling_end_time <= $nowUtc;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,17 @@ final class DrawResultViewService
|
||||
* consolation: array<int, string>
|
||||
* }
|
||||
*/
|
||||
/** 已发布批次的头奖 4D 号码;未发布或缺失时返回空字符串。 */
|
||||
public function firstPrizeNumber4dForDraw(Draw $draw): string
|
||||
{
|
||||
$summary = $this->summarizeDraw($draw);
|
||||
if ($summary === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) ($summary['results']['1st'] ?? '');
|
||||
}
|
||||
|
||||
public function numbersFromItems(Collection $items): array
|
||||
{
|
||||
$byType = [
|
||||
|
||||
@@ -32,8 +32,10 @@ final class DrawRngRunner
|
||||
'draw.require_manual_review',
|
||||
(bool) config('lottery.draw.require_manual_review', false),
|
||||
);
|
||||
$seedMaterial = bin2hex(random_bytes(32));
|
||||
$rngSeedHash = hash('sha256', $seedMaterial);
|
||||
$seedHex = DrawRngSeedDerivation::generateSeedHex();
|
||||
$rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
|
||||
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
|
||||
$derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id);
|
||||
|
||||
$nextVersion = max(1, (int) $draw->current_result_version + 1);
|
||||
|
||||
@@ -42,28 +44,24 @@ final class DrawRngRunner
|
||||
'result_version' => $nextVersion,
|
||||
'source_type' => DrawResultSourceType::Rng->value,
|
||||
'rng_seed_hash' => $rngSeedHash,
|
||||
'raw_seed_encrypted' => null,
|
||||
'raw_seed_encrypted' => $rawSeedEncrypted,
|
||||
'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => $manualReview ? null : now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||
$suffix3 = substr($num, -3);
|
||||
$suffix2 = substr($num, -2);
|
||||
|
||||
foreach ($derivedRows as $row) {
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => $suffix3,
|
||||
'suffix_2d' => $suffix2,
|
||||
'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null,
|
||||
'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null,
|
||||
'prize_type' => $row['prize_type'],
|
||||
'prize_index' => $row['prize_index'],
|
||||
'number_4d' => $row['number_4d'],
|
||||
'suffix_3d' => $row['suffix_3d'],
|
||||
'suffix_2d' => $row['suffix_2d'],
|
||||
'head_digit' => $row['head_digit'],
|
||||
'tail_digit' => $row['tail_digit'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
126
app/Services/Draw/DrawRngSeedDerivation.php
Normal file
126
app/Services/Draw/DrawRngSeedDerivation.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
|
||||
/**
|
||||
* RNG 种子:CSPRNG 采集、SHA-256 摘要、Laravel 加密落库、确定性派生 4 位号码(算法 v1)。
|
||||
*
|
||||
* 验收:解密种子 → sha256(seed_hex) === rng_seed_hash → 复算 23 组号码与 draw_result_items 一致。
|
||||
*/
|
||||
final class DrawRngSeedDerivation
|
||||
{
|
||||
public const ALGORITHM_VERSION = 'v1';
|
||||
|
||||
/** 生成 32 字节随机种子(十六进制,64 字符) */
|
||||
public static function generateSeedHex(): string
|
||||
{
|
||||
return bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
public static function hashSeedHex(string $seedHex): string
|
||||
{
|
||||
return hash('sha256', $seedHex);
|
||||
}
|
||||
|
||||
public static function encryptSeedHex(string $seedHex): string
|
||||
{
|
||||
return Crypt::encryptString($seedHex);
|
||||
}
|
||||
|
||||
public static function decryptSeedHex(string $encrypted): string
|
||||
{
|
||||
try {
|
||||
return Crypt::decryptString($encrypted);
|
||||
} catch (DecryptException $e) {
|
||||
throw new \InvalidArgumentException('RNG seed decrypt failed', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 由种子确定性派生第 $slotIndex 槽位的 4 位号码(0000–9999)。
|
||||
*/
|
||||
public static function deriveNumber4d(string $seedHex, int $drawId, int $slotIndex): string
|
||||
{
|
||||
$seedBinary = hex2bin($seedHex);
|
||||
if ($seedBinary === false || strlen($seedBinary) !== 32) {
|
||||
throw new \InvalidArgumentException('RNG seed must be 64 hex chars (32 bytes).');
|
||||
}
|
||||
|
||||
$message = self::ALGORITHM_VERSION.'|draw:'.$drawId.'|slot:'.$slotIndex;
|
||||
$digest = hash_hmac('sha256', $message, $seedBinary, true);
|
||||
$chunk = substr($digest, 0, 4);
|
||||
$unpacked = unpack('V', $chunk);
|
||||
$value = ((int) ($unpacked[1] ?? 0)) % 10_000;
|
||||
|
||||
return str_pad((string) $value, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{prize_type: string, prize_index: int, number_4d: string, suffix_3d: string, suffix_2d: string, head_digit: int|null, tail_digit: int|null}>
|
||||
*/
|
||||
public static function deriveAllSlotRows(string $seedHex, int $drawId): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach (DrawPrizeLayout::slots() as $slotIndex => $slot) {
|
||||
$num = self::deriveNumber4d($seedHex, $drawId, $slotIndex);
|
||||
$rows[] = [
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => substr($num, -3),
|
||||
'suffix_2d' => substr($num, -2),
|
||||
'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null,
|
||||
'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 审计:校验批次种子摘要、密文可解密且号码可由种子复算。 */
|
||||
public static function verifyBatchAudit(DrawResultBatch $batch, Draw $draw): bool
|
||||
{
|
||||
if ($batch->source_type !== 'rng') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encrypted = $batch->raw_seed_encrypted;
|
||||
$hash = $batch->rng_seed_hash;
|
||||
if (! is_string($encrypted) || $encrypted === '' || ! is_string($hash) || $hash === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$seedHex = self::decryptSeedHex($encrypted);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::hashSeedHex($seedHex) !== $hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected = self::deriveAllSlotRows($seedHex, (int) $draw->id);
|
||||
$items = $batch->items()->get();
|
||||
|
||||
if ($items->count() !== count($expected)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($expected as $row) {
|
||||
$item = $items->first(fn (DrawResultItem $i) => $i->prize_type === $row['prize_type']
|
||||
&& (int) $i->prize_index === $row['prize_index']);
|
||||
if ($item === null || $item->number_4d !== $row['number_4d']) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Services\LotterySettings;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\SettlementTickFinalizer;
|
||||
|
||||
/**
|
||||
* 每分钟调度:期号状态推进 → RNG(若到期号)→ 冷静期结束时进入结算态 → 补齐未来缓冲。
|
||||
@@ -21,11 +22,14 @@ final class DrawTickService
|
||||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||||
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
|
||||
private readonly SettlementOrchestrator $settlementOrchestrator,
|
||||
private readonly SettlementTickFinalizer $settlementFinalizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status_updates: array<string, int>,
|
||||
* settling_settled: int,
|
||||
* settlement_finalized: array{approved: int, paid: int},
|
||||
* rng_rung: int,
|
||||
* rng_errors: array<int, string>,
|
||||
* planned: array<string, int>
|
||||
@@ -45,6 +49,7 @@ final class DrawTickService
|
||||
];
|
||||
|
||||
$settlingSettled = $this->settleSettlingDraws();
|
||||
$settlementFinalized = $this->settlementFinalizer->finalizePendingBatches();
|
||||
|
||||
$rngOutcome = $this->rng->runDue($nowUtc);
|
||||
$planned = $this->planner->ensureBuffer($nowUtc);
|
||||
@@ -52,6 +57,7 @@ final class DrawTickService
|
||||
$report = [
|
||||
'status_updates' => $statusUpdates,
|
||||
'settling_settled' => $settlingSettled,
|
||||
'settlement_finalized' => $settlementFinalized,
|
||||
'rng_rung' => $rngOutcome['rung'],
|
||||
'rng_errors' => $rngOutcome['errors'],
|
||||
'planned' => $planned,
|
||||
|
||||
@@ -22,7 +22,7 @@ final class LotteryHallRealtimeBroadcaster
|
||||
private readonly DrawHallSnapshotBuilder $snapshot,
|
||||
) {}
|
||||
|
||||
/** 每秒调度:`draw.countdown` 仅发送轻量心跳,不重查全量大厅快照。 */
|
||||
/** 每秒调度:`draw.countdown` 推送大厅快照(与 GET draw/current 一致),避免仅本地倒计时无法切期。 */
|
||||
public function countdownPulse(): void
|
||||
{
|
||||
if (! $this->driverSupportsRealtime()) {
|
||||
@@ -31,7 +31,7 @@ final class LotteryHallRealtimeBroadcaster
|
||||
|
||||
$ms = (int) floor(microtime(true) * 1000);
|
||||
|
||||
broadcast(new DrawCountdownBroadcast(null, $ms));
|
||||
broadcast(new DrawCountdownBroadcast($this->snapshot->build(), $ms));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user