feat: 更新玩法配置管理,简化字段并增强功能

- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。
- 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。
- 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
2026-05-25 14:34:24 +08:00
parent 270d2e9af1
commit e27a00f260
74 changed files with 4469 additions and 280 deletions

View File

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

View File

@@ -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 = [

View File

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

View 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 位号码00009999
*/
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;
}
}

View File

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

View File

@@ -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));
}
/**