feat: 彩票业务迁移并补全后台权限与代理结算体系

This commit is contained in:
2026-06-10 10:29:43 +08:00
parent bbdb69dabb
commit 1948b10fe6
108 changed files with 7083 additions and 5033 deletions

View File

@@ -12,7 +12,7 @@ final class LotteryDatabaseInitCommand extends Command
{--no-demo : 禁止写入演示数据}
{--skip-auth-sync : 跳过后台权限注册表同步}';
protected $description = '统一初始化数据库:迁移/基线、基础种子、后台权限同步,以及非生产演示数据';
protected $description = '统一初始化数据库:迁移、基础种子、后台权限同步,以及非生产演示数据';
public function handle(): int
{

View File

@@ -3,17 +3,27 @@
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
final class LotteryHallCountdownCommand extends Command
{
protected $signature = 'lottery:hall-countdown';
protected $description = '大厅 countdown WebSocket`draw.countdown`每秒;见界面文档 §2.1';
protected $description = '大厅 countdown WebSocket`draw.countdown`按配置频率;见界面文档 §2.1';
public function handle(LotteryHallRealtimeBroadcaster $broadcaster): int
{
$startedAt = hrtime(true);
$broadcaster->countdownPulse();
$elapsedMs = (int) round((hrtime(true) - $startedAt) / 1_000_000);
if ($elapsedMs >= (int) config('lottery.realtime_hall_countdown_warn_threshold_ms', 800)) {
Log::warning('lottery:hall-countdown exceeded warn threshold', [
'elapsed_ms' => $elapsedMs,
'threshold_ms' => (int) config('lottery.realtime_hall_countdown_warn_threshold_ms', 800),
]);
}
return self::SUCCESS;
}

View File

@@ -9,6 +9,7 @@ use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Lottery\DrawResultBatchStatus;
use Illuminate\Support\Facades\Cache;
use App\Services\Jackpot\JackpotSummaryService;
use App\Services\LotterySettings;
@@ -19,6 +20,12 @@ use App\Services\LotterySettings;
*/
final class DrawHallSnapshotBuilder
{
private const JACKPOT_CACHE_TTL_SECONDS = 5;
private const RISK_ALERTS_CACHE_TTL_SECONDS = 2;
private const RESULT_ITEMS_CACHE_TTL_SECONDS = 5;
public function __construct(
private readonly JackpotSummaryService $jackpotSummary,
) {}
@@ -108,6 +115,20 @@ final class DrawHallSnapshotBuilder
DrawStatus::Settled->value,
DrawStatus::Cancelled->value,
])
->where(function ($q) use ($nowUtc): void {
$q->where('status', '!=', DrawStatus::Pending->value)
->orWhere(function ($q2) use ($nowUtc): void {
$q2->where('status', DrawStatus::Pending->value)
->where(function ($q3) use ($nowUtc): void {
$q3->whereNull('close_time')
->orWhere('close_time', '>', $nowUtc);
})
->where(function ($q3) use ($nowUtc): void {
$q3->whereNull('draw_time')
->orWhere('draw_time', '>', $nowUtc);
});
});
})
->where(function ($q) use ($nowUtc): void {
$q->where(function ($q2) use ($nowUtc): void {
$q2->whereNotNull('close_time')
@@ -258,56 +279,19 @@ final class DrawHallSnapshotBuilder
'cooling_end_time' => $target->cooling_end_time?->toIso8601String(),
'seconds_remaining_in_cooldown' => $coolingRemain,
'jackpot_currency_code' => $currencyCode,
'jackpot' => $this->jackpotSummary->summary($currencyCode),
'jackpot' => $this->cachedJackpotSummary($currencyCode),
];
$riskAlerts = RiskPool::query()
->where('draw_id', $target->id)
->where(function ($q): void {
$q->where('sold_out_status', 1)
->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8');
})
->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC')
->orderByDesc('locked_amount')
->orderBy('normalized_number')
->limit(500)
->get(['normalized_number', 'sold_out_status'])
->map(fn ($row) => [
'normalized_number' => (string) $row->normalized_number,
'status' => (int) $row->sold_out_status === 1 ? 'sold_out' : 'warning',
])
->values()
->all();
$payload['risk_pool_alerts'] = $riskAlerts;
$payload['risk_pool_alerts'] = $this->cachedRiskAlerts((int) $target->id);
if ($this->showsPublishedResults((string) $target->status)) {
$batchId = DrawResultBatch::query()
->where('draw_id', $target->id)
->where('result_version', (int) $target->current_result_version)
->where('status', DrawResultBatchStatus::Published->value)
->value('id');
$resultItems = $this->cachedPublishedResultItems(
(int) $target->id,
(int) $target->current_result_version,
);
if ($batchId !== null) {
$payload['result_items'] = DrawResultItem::query()
->where('result_batch_id', $batchId)
->orderBy('prize_type')
->orderBy('prize_index')
->get([
'prize_type', 'prize_index',
'number_4d', 'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit',
])
->map(fn ($row) => [
'prize_type' => $row->prize_type,
'prize_index' => (int) $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,
])
->values()
->all();
if ($resultItems !== null) {
$payload['result_items'] = $resultItems;
}
$payload['result_version'] = (int) $target->current_result_version;
@@ -327,4 +311,89 @@ final class DrawHallSnapshotBuilder
return LotterySettings::defaultCurrency();
}
/**
* @return array<string, mixed>
*/
private function cachedJackpotSummary(string $currencyCode): array
{
$cacheKey = sprintf('hall_snapshot:jackpot:%s', strtoupper($currencyCode));
/** @var array<string, mixed> */
return Cache::remember($cacheKey, self::JACKPOT_CACHE_TTL_SECONDS, fn (): array => $this->jackpotSummary->summary($currencyCode));
}
/**
* @return list<array{normalized_number: string, status: string}>
*/
private function cachedRiskAlerts(int $drawId): array
{
$cacheKey = sprintf('hall_snapshot:risk_alerts:%d', $drawId);
/** @var list<array{normalized_number: string, status: string}> */
return Cache::remember($cacheKey, self::RISK_ALERTS_CACHE_TTL_SECONDS, function () use ($drawId): array {
return RiskPool::query()
->where('draw_id', $drawId)
->where(function ($q): void {
$q->where('sold_out_status', 1)
->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8');
})
->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC')
->orderByDesc('locked_amount')
->orderBy('normalized_number')
->limit(500)
->get(['normalized_number', 'sold_out_status'])
->map(fn ($row) => [
'normalized_number' => (string) $row->normalized_number,
'status' => (int) $row->sold_out_status === 1 ? 'sold_out' : 'warning',
])
->values()
->all();
});
}
/**
* @return list<array<string, mixed>>|null
*/
private function cachedPublishedResultItems(int $drawId, int $resultVersion): ?array
{
if ($resultVersion <= 0) {
return null;
}
$cacheKey = sprintf('hall_snapshot:result_items:%d:%d', $drawId, $resultVersion);
/** @var list<array<string, mixed>>|null */
return Cache::remember($cacheKey, self::RESULT_ITEMS_CACHE_TTL_SECONDS, function () use ($drawId, $resultVersion): ?array {
$batchId = DrawResultBatch::query()
->where('draw_id', $drawId)
->where('result_version', $resultVersion)
->where('status', DrawResultBatchStatus::Published->value)
->value('id');
if ($batchId === null) {
return null;
}
return DrawResultItem::query()
->where('result_batch_id', $batchId)
->orderBy('prize_type')
->orderBy('prize_index')
->get([
'prize_type', 'prize_index',
'number_4d', 'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit',
])
->map(fn ($row) => [
'prize_type' => $row->prize_type,
'prize_index' => (int) $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,
])
->values()
->all();
});
}
}

View File

@@ -93,6 +93,7 @@ final class DrawRngRunner
->orWhereDoesntHave('resultBatches');
})
->orderBy('draw_time')
->limit((int) config('lottery.draw_tick_rng_limit', 3))
->pluck('id');
foreach ($ids as $drawId) {

View File

@@ -5,6 +5,7 @@ namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use Illuminate\Support\Facades\Log;
use App\Services\LotterySettings;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementTickFinalizer;
@@ -38,21 +39,23 @@ final class DrawTickService
public function tick(?Carbon $now = null): array
{
$nowUtc = ($now ?? Carbon::now())->utc();
$startedAt = hrtime(true);
$stageTimings = [];
$hallFpBefore = $this->hallSnapshot->hallTargetFingerprint($nowUtc);
$hallFpBefore = $this->measureStage('hall_fp_before', $stageTimings, fn () => $this->hallSnapshot->hallTargetFingerprint($nowUtc));
$statusUpdates = [
$statusUpdates = $this->measureStage('status_updates', $stageTimings, fn (): array => [
'pending_to_open_or_later' => $this->promoteStalePendingRows($nowUtc),
'open_to_closing_or_closed' => $this->openToClosingOrClosed($nowUtc),
'closing_to_closed' => $this->closingToClosed($nowUtc),
'cooldown_to_settling' => $this->cooldownToSettling($nowUtc),
];
]);
$settlingSettled = $this->settleSettlingDraws();
$settlementFinalized = $this->settlementFinalizer->finalizePendingBatches();
$settlingSettled = $this->measureStage('settle_settling_draws', $stageTimings, fn (): int => $this->settleSettlingDraws());
$settlementFinalized = $this->measureStage('finalize_pending_batches', $stageTimings, fn (): array => $this->settlementFinalizer->finalizePendingBatches());
$rngOutcome = $this->rng->runDue($nowUtc);
$planned = $this->planner->ensureBuffer($nowUtc);
$rngOutcome = $this->measureStage('rng_run_due', $stageTimings, fn (): array => $this->rng->runDue($nowUtc));
$planned = $this->measureStage('ensure_buffer', $stageTimings, fn (): array => $this->planner->ensureBuffer($nowUtc));
$report = [
'status_updates' => $statusUpdates,
@@ -63,10 +66,14 @@ final class DrawTickService
'planned' => $planned,
];
$snapshotAfter = $this->hallSnapshot->build($nowUtc);
$hallFpAfter = $this->hallSnapshot->hallTargetFingerprint($nowUtc);
$snapshotAfter = $this->measureStage('hall_snapshot_after', $stageTimings, fn () => $this->hallSnapshot->build($nowUtc));
$hallFpAfter = $this->measureStage('hall_fp_after', $stageTimings, fn () => $this->hallSnapshot->hallTargetFingerprint($nowUtc));
$this->hallRealtime->notifyStatusChangeIfHallDbChanged($hallFpBefore, $hallFpAfter, $snapshotAfter);
$this->measureStage('notify_status_change', $stageTimings, function () use ($hallFpBefore, $hallFpAfter, $snapshotAfter): void {
$this->hallRealtime->notifyStatusChangeIfHallDbChanged($hallFpBefore, $hallFpAfter, $snapshotAfter);
});
$this->logIfSlow($startedAt, $stageTimings, $report);
return $report;
}
@@ -156,7 +163,11 @@ final class DrawTickService
}
$n = 0;
$ids = Draw::query()->where('status', DrawStatus::Settling->value)->pluck('id');
$ids = Draw::query()
->where('status', DrawStatus::Settling->value)
->orderBy('id')
->limit((int) config('lottery.draw_tick_settle_limit', 3))
->pluck('id');
foreach ($ids as $drawId) {
$draw = Draw::query()->find($drawId);
if ($draw === null) {
@@ -173,4 +184,49 @@ final class DrawTickService
return $n;
}
/**
* @template T
* @param array<string, int> $stageTimings
* @param \Closure(): T $callback
* @return T
*/
private function measureStage(string $stage, array &$stageTimings, \Closure $callback): mixed
{
$startedAt = hrtime(true);
$result = $callback();
$stageTimings[$stage] = (int) round((hrtime(true) - $startedAt) / 1_000_000);
return $result;
}
/**
* @param array<string, int> $stageTimings
* @param array<string, mixed> $report
*/
private function logIfSlow(int $startedAt, array $stageTimings, array $report): void
{
$totalMs = (int) round((hrtime(true) - $startedAt) / 1_000_000);
$thresholdMs = (int) config('lottery.draw_tick_warn_threshold_ms', 1500);
$stageThresholdMs = (int) config('lottery.draw_tick_stage_warn_threshold_ms', 500);
$slowStages = array_filter($stageTimings, fn (int $elapsedMs): bool => $elapsedMs >= $stageThresholdMs);
if ($totalMs < $thresholdMs && $slowStages === []) {
return;
}
Log::warning('lottery:draw-tick exceeded warn threshold', [
'elapsed_ms' => $totalMs,
'threshold_ms' => $thresholdMs,
'stage_threshold_ms' => $stageThresholdMs,
'slow_stages_ms' => $slowStages,
'all_stages_ms' => $stageTimings,
'status_update_rows' => array_sum($report['status_updates'] ?? []),
'settling_settled' => $report['settling_settled'] ?? 0,
'approved_batches' => $report['settlement_finalized']['approved'] ?? 0,
'paid_batches' => $report['settlement_finalized']['paid'] ?? 0,
'rng_rung' => $report['rng_rung'] ?? 0,
'planned_created' => $report['planned']['created'] ?? 0,
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Draw;
use Carbon\Carbon;
use App\Events\OddsUpdateBroadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use App\Events\PlayToggleBroadcast;
@@ -11,6 +12,7 @@ use App\Events\JackpotBurstBroadcast;
use App\Events\DrawCountdownBroadcast;
use App\Events\DrawStatusChangeBroadcast;
use App\Events\DrawResultPublishedBroadcast;
use Illuminate\Support\Facades\Cache;
/**
* 对齐界面文档 §2.1:大厅公共频道广播(`lottery-hall`)。
@@ -19,20 +21,33 @@ use App\Events\DrawResultPublishedBroadcast;
*/
final class LotteryHallRealtimeBroadcaster
{
private const COUNTDOWN_FP_CACHE_KEY = 'lottery:hall:countdown:last-fingerprint';
public function __construct(
private readonly DrawHallSnapshotBuilder $snapshot,
) {}
/** 每秒调度:`draw.countdown` 推送大厅快照(与 GET draw/current 一致),避免仅本地倒计时无法切期。 */
/** 每秒调度:边界变化立刻推送,完整快照按低频校准,避免仅本地倒计时无法切期。 */
public function countdownPulse(): void
{
if (! $this->driverSupportsRealtime()) {
return;
}
$nowUtc = Carbon::now()->utc();
$ms = (int) floor(microtime(true) * 1000);
$fingerprint = $this->snapshot->hallTargetFingerprint($nowUtc);
$lastFingerprint = Cache::get(self::COUNTDOWN_FP_CACHE_KEY);
$stateChanged = $this->fingerprintChanged($lastFingerprint, $fingerprint);
$shouldSync = $this->shouldBroadcastSyncPulse($nowUtc);
broadcast(new DrawCountdownBroadcast($this->snapshot->build(), $ms));
Cache::put(self::COUNTDOWN_FP_CACHE_KEY, $fingerprint, now()->addMinutes(10));
if (! $stateChanged && ! $shouldSync) {
return;
}
broadcast(new DrawCountdownBroadcast($this->snapshot->build($nowUtc), $ms));
}
/**
@@ -197,4 +212,24 @@ final class LotteryHallRealtimeBroadcaster
return ! in_array($driver, ['null', 'log'], true);
}
private function shouldBroadcastSyncPulse(Carbon $nowUtc): bool
{
$interval = match ((int) config('lottery.realtime_hall_countdown_sync_interval_seconds', 5)) {
1, 2, 5, 10 => (int) config('lottery.realtime_hall_countdown_sync_interval_seconds', 5),
default => 5,
};
return $interval === 1 || ((int) $nowUtc->format('s')) % $interval === 0;
}
private function fingerprintChanged(mixed $before, ?array $after): bool
{
if (! is_array($before)) {
return $after !== null;
}
return ($before['draw_no'] ?? null) !== ($after['draw_no'] ?? null)
|| ($before['status'] ?? null) !== ($after['status'] ?? null);
}
}

View File

@@ -41,16 +41,16 @@ final class SettlementOrchestrator
*/
public function trySettleDraw(Draw $draw): bool
{
return (bool) DB::transaction(function () use ($draw): bool {
$afterCommit = DB::transaction(function () use ($draw): array {
/** @var Draw $locked */
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
if ($locked->status === DrawStatus::Settled->value) {
return false;
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
if ($locked->status !== DrawStatus::Settling->value) {
return false;
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$publishedBatch = DrawResultBatch::query()
@@ -61,7 +61,7 @@ final class SettlementOrchestrator
->first();
if ($publishedBatch === null) {
return false;
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$existingDone = SettlementBatch::query()
@@ -81,7 +81,11 @@ final class SettlementOrchestrator
'settle_version' => (int) $existingDone->settle_version,
])->save();
return true;
return [
'handled' => true,
'jackpot_bursts' => [],
'should_notify_status' => true,
];
}
$items = DrawResultItem::query()
@@ -224,21 +228,39 @@ final class SettlementOrchestrator
'settle_version' => $nextSettleVersion,
])->save();
foreach ($jackpotBursts as $burst) {
$this->hallRealtime->notifyJackpotBurst(
(int) $locked->id,
(string) $locked->draw_no,
$board->firstPrizeNumber4d(),
(string) $burst['currency'],
(int) $burst['payout'],
(int) $burst['winner_count'],
(string) $burst['trigger'],
(int) $burst['pool_after'],
);
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
}
return true;
return [
'handled' => true,
'jackpot_bursts' => array_map(fn (array $burst): array => [
'draw_id' => (int) $locked->id,
'draw_no' => (string) $locked->draw_no,
'first_prize_number' => $board->firstPrizeNumber4d(),
'currency' => (string) $burst['currency'],
'payout' => (int) $burst['payout'],
'winner_count' => (int) $burst['winner_count'],
'trigger' => (string) $burst['trigger'],
'pool_after' => (int) $burst['pool_after'],
], $jackpotBursts),
'should_notify_status' => true,
];
});
foreach ($afterCommit['jackpot_bursts'] as $burst) {
$this->hallRealtime->notifyJackpotBurst(
$burst['draw_id'],
$burst['draw_no'],
$burst['first_prize_number'],
$burst['currency'],
$burst['payout'],
$burst['winner_count'],
$burst['trigger'],
$burst['pool_after'],
);
}
if (($afterCommit['should_notify_status'] ?? false) === true) {
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
}
return (bool) ($afterCommit['handled'] ?? false);
}
}

View File

@@ -29,6 +29,7 @@ final class SettlementTickFinalizer
$pending = SettlementBatch::query()
->where('status', SettlementBatchStatus::PendingReview->value)
->orderBy('id')
->limit((int) config('lottery.draw_tick_finalize_limit', 5))
->get();
foreach ($pending as $batch) {