feat: 更新玩法配置管理,简化字段并增强功能
- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。 - 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。 - 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
82
app/Services/Admin/AdminDashboardAnalyticsBuilder.php
Normal file
82
app/Services/Admin/AdminDashboardAnalyticsBuilder.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
|
||||
/**
|
||||
* 仪表盘可筛选分析数据:区间汇总、日趋势、玩法拆解。
|
||||
*/
|
||||
final class AdminDashboardAnalyticsBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminReportQueryService $reportQuery,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* period?: string,
|
||||
* date_from?: string|null,
|
||||
* date_to?: string|null,
|
||||
* metric?: string,
|
||||
* play_code?: string|null
|
||||
* } $filters
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function build(AdminUser $admin, array $filters): ?array
|
||||
{
|
||||
if (! $this->canView($admin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$period = (string) ($filters['period'] ?? 'last_7_days');
|
||||
$metric = (string) ($filters['metric'] ?? 'overview');
|
||||
$playCode = isset($filters['play_code']) && $filters['play_code'] !== ''
|
||||
? (string) $filters['play_code']
|
||||
: null;
|
||||
|
||||
$range = $this->reportQuery->resolveDashboardPeriod(
|
||||
$period,
|
||||
isset($filters['date_from']) ? (string) $filters['date_from'] : null,
|
||||
isset($filters['date_to']) ? (string) $filters['date_to'] : null,
|
||||
);
|
||||
|
||||
$dateFrom = $range['date_from'];
|
||||
$dateTo = $range['date_to'];
|
||||
|
||||
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo);
|
||||
|
||||
return [
|
||||
'period' => $period,
|
||||
'metric' => $metric,
|
||||
'play_code' => $playCode,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo),
|
||||
'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo),
|
||||
'daily_series' => $trend['series'],
|
||||
'chart_meta' => [
|
||||
'chart_date_from' => $trend['chart_date_from'],
|
||||
'chart_date_to' => $trend['chart_date_to'],
|
||||
'truncated' => $trend['truncated'],
|
||||
'span_days' => $trend['span_days'],
|
||||
],
|
||||
'play_breakdown' => $this->reportQuery->playDimensionBreakdownRows(
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$playCode,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function canView(AdminUser $admin): bool
|
||||
{
|
||||
return $admin->hasAdminPermission('prd.dashboard.view')
|
||||
|| $admin->hasAdminPermission('prd.draw_result.manage')
|
||||
|| $admin->hasAdminPermission('prd.draw_result.view')
|
||||
|| $admin->hasAdminPermission('prd.risk.view')
|
||||
|| $admin->hasAdminPermission('prd.risk.manage')
|
||||
|| $admin->hasAdminPermission('prd.report.view');
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ final class AdminDashboardSnapshotBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||||
private readonly AdminReportQueryService $reportQuery,
|
||||
) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
@@ -34,6 +35,8 @@ final class AdminDashboardSnapshotBuilder
|
||||
$out = [
|
||||
'hall' => $hall,
|
||||
'resolved_draw' => null,
|
||||
'today_finance' => null,
|
||||
'lifetime_finance' => null,
|
||||
'finance' => null,
|
||||
'draw' => null,
|
||||
'risk' => null,
|
||||
@@ -67,6 +70,8 @@ final class AdminDashboardSnapshotBuilder
|
||||
];
|
||||
|
||||
if ($canDraw) {
|
||||
$out['today_finance'] = $this->todayFinanceSummary();
|
||||
$out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals();
|
||||
$out['finance'] = $this->financeSummary($draw);
|
||||
$out['draw'] = $this->drawPanel($draw);
|
||||
$out['risk'] = $this->riskPanel($draw);
|
||||
@@ -81,8 +86,11 @@ final class AdminDashboardSnapshotBuilder
|
||||
|
||||
private function canDrawFinanceAndRisk(AdminUser $admin): bool
|
||||
{
|
||||
return $admin->hasAdminPermission('prd.draw_result.manage')
|
||||
|| $admin->hasAdminPermission('prd.draw_result.view');
|
||||
return $admin->hasAdminPermission('prd.dashboard.view')
|
||||
|| $admin->hasAdminPermission('prd.draw_result.manage')
|
||||
|| $admin->hasAdminPermission('prd.draw_result.view')
|
||||
|| $admin->hasAdminPermission('prd.risk.view')
|
||||
|| $admin->hasAdminPermission('prd.risk.manage');
|
||||
}
|
||||
|
||||
private function canWalletReconcile(AdminUser $admin): bool
|
||||
@@ -99,6 +107,36 @@ final class AdminDashboardSnapshotBuilder
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按业务日汇总「今日」投注/派彩/盈亏(与报表中心 daily-profit 口径一致)。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function todayFinanceSummary(): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$rows = $this->reportQuery->dailyProfitRows($today, $today);
|
||||
$row = $rows[0] ?? [
|
||||
'business_date' => $today,
|
||||
'total_bet_minor' => 0,
|
||||
'total_payout_minor' => 0,
|
||||
'approx_house_gross_minor' => 0,
|
||||
];
|
||||
|
||||
$currencyCode = (string) (TicketOrder::query()
|
||||
->join('draws', 'draws.id', '=', 'ticket_orders.draw_id')
|
||||
->where('draws.business_date', $today)
|
||||
->value('ticket_orders.currency_code') ?? '');
|
||||
|
||||
return [
|
||||
'business_date' => (string) $row['business_date'],
|
||||
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
|
||||
'total_bet_minor' => (int) $row['total_bet_minor'],
|
||||
'total_payout_minor' => (int) $row['total_payout_minor'],
|
||||
'approx_house_gross_minor' => (int) $row['approx_house_gross_minor'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function financeSummary(Draw $draw): array
|
||||
{
|
||||
|
||||
@@ -28,6 +28,180 @@ final class AdminReportQueryService
|
||||
return ['date_from' => $dateFrom, 'date_to' => $dateTo];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{date_from: string, date_to: string}
|
||||
*/
|
||||
public function resolveDashboardPeriod(string $period, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
|
||||
$range = match ($period) {
|
||||
'today' => ['date_from' => $today, 'date_to' => $today],
|
||||
'last_7_days' => [
|
||||
'date_from' => now()->subDays(6)->toDateString(),
|
||||
'date_to' => $today,
|
||||
],
|
||||
'last_30_days' => [
|
||||
'date_from' => now()->subDays(29)->toDateString(),
|
||||
'date_to' => $today,
|
||||
],
|
||||
'this_month' => [
|
||||
'date_from' => now()->startOfMonth()->toDateString(),
|
||||
'date_to' => $today,
|
||||
],
|
||||
'lifetime' => $this->lifetimeBusinessDateBounds(),
|
||||
'custom' => [
|
||||
'date_from' => $dateFrom !== null && $dateFrom !== '' ? $dateFrom : $today,
|
||||
'date_to' => $dateTo !== null && $dateTo !== '' ? $dateTo : $today,
|
||||
],
|
||||
default => ['date_from' => $today, 'date_to' => $today],
|
||||
};
|
||||
|
||||
$from = $range['date_from'];
|
||||
$to = $range['date_to'];
|
||||
if ($from > $to) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
|
||||
return ['date_from' => $from, 'date_to' => $to];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{date_from: string, date_to: string}
|
||||
*/
|
||||
private function lifetimeBusinessDateBounds(): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$bounds = DB::table('draws as d')
|
||||
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
|
||||
->selectRaw('MIN(d.business_date) as date_from')
|
||||
->selectRaw('MAX(d.business_date) as date_to')
|
||||
->first();
|
||||
|
||||
$from = $this->formatBusinessDateValue($bounds?->date_from) ?? $today;
|
||||
$to = $this->formatBusinessDateValue($bounds?->date_to) ?? $today;
|
||||
|
||||
return ['date_from' => $from, 'date_to' => $to];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total_bet_minor: int,
|
||||
* total_payout_minor: int,
|
||||
* approx_house_gross_minor: int,
|
||||
* draw_count: int,
|
||||
* business_day_count: int
|
||||
* }
|
||||
*/
|
||||
public function periodFinanceTotals(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$rows = $this->dailyProfitRows($dateFrom, $dateTo);
|
||||
$totalBet = 0;
|
||||
$totalPayout = 0;
|
||||
$totalGross = 0;
|
||||
foreach ($rows as $row) {
|
||||
$totalBet += (int) $row['total_bet_minor'];
|
||||
$totalPayout += (int) $row['total_payout_minor'];
|
||||
$totalGross += (int) $row['approx_house_gross_minor'];
|
||||
}
|
||||
|
||||
$activity = DB::table('draws as d')
|
||||
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
|
||||
->whereBetween('d.business_date', [$dateFrom, $dateTo])
|
||||
->selectRaw('COUNT(DISTINCT d.id) as draw_count')
|
||||
->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_bet_minor' => $totalBet,
|
||||
'total_payout_minor' => $totalPayout,
|
||||
'approx_house_gross_minor' => $totalGross,
|
||||
'draw_count' => (int) ($activity->draw_count ?? 0),
|
||||
'business_day_count' => (int) ($activity->business_day_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续业务日序列(无数据日补零),用于趋势图。
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90): array
|
||||
{
|
||||
$from = Carbon::parse($dateFrom)->startOfDay();
|
||||
$to = Carbon::parse($dateTo)->startOfDay();
|
||||
$spanDays = (int) $from->diffInDays($to) + 1;
|
||||
|
||||
$chartFrom = $dateFrom;
|
||||
$chartTo = $dateTo;
|
||||
$truncated = false;
|
||||
if ($spanDays > $maxDays) {
|
||||
$chartFrom = $to->copy()->subDays($maxDays - 1)->format('Y-m-d');
|
||||
$truncated = true;
|
||||
}
|
||||
|
||||
$indexed = collect($this->dailyProfitRows($chartFrom, $chartTo))->keyBy('business_date');
|
||||
$cursor = Carbon::parse($chartFrom)->startOfDay();
|
||||
$end = Carbon::parse($chartTo)->startOfDay();
|
||||
$series = [];
|
||||
|
||||
while ($cursor <= $end) {
|
||||
$key = $cursor->format('Y-m-d');
|
||||
$series[] = $indexed[$key] ?? [
|
||||
'business_date' => $key,
|
||||
'total_bet_minor' => 0,
|
||||
'total_payout_minor' => 0,
|
||||
'approx_house_gross_minor' => 0,
|
||||
];
|
||||
$cursor->addDay();
|
||||
}
|
||||
|
||||
return [
|
||||
'series' => $series,
|
||||
'chart_date_from' => $chartFrom,
|
||||
'chart_date_to' => $chartTo,
|
||||
'truncated' => $truncated,
|
||||
'span_days' => $spanDays,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function playDimensionBreakdownRows(
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
?string $playCode = null,
|
||||
int $limit = 12,
|
||||
): array {
|
||||
return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo)
|
||||
->orderByDesc('total_bet_minor')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(static function (object $row): array {
|
||||
return [
|
||||
'play_code' => (string) $row->play_code,
|
||||
'dimension' => (int) $row->dimension,
|
||||
'total_bet_minor' => (int) $row->total_bet_minor,
|
||||
'total_payout_minor' => (int) $row->total_payout_minor,
|
||||
'approx_house_gross_minor' => (int) $row->approx_house_gross_minor,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo): ?string
|
||||
{
|
||||
$currencyCode = (string) (DB::table('ticket_orders as o')
|
||||
->join('draws as d', 'd.id', '=', 'o.draw_id')
|
||||
->whereBetween('d.business_date', [$dateFrom, $dateTo])
|
||||
->orderByDesc('o.id')
|
||||
->value('o.currency_code') ?? '');
|
||||
|
||||
return $currencyCode !== '' ? $currencyCode : null;
|
||||
}
|
||||
|
||||
public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$rows = $this->dailyProfitRows($dateFrom, $dateTo);
|
||||
@@ -80,6 +254,57 @@ final class AdminReportQueryService
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 全平台历史累计投注/派彩/盈亏(与 daily-profit 同口径,不限业务日)。
|
||||
*
|
||||
* @return array{
|
||||
* currency_code: ?string,
|
||||
* total_bet_minor: int,
|
||||
* total_payout_minor: int,
|
||||
* approx_house_gross_minor: int,
|
||||
* draw_count: int,
|
||||
* business_day_count: int,
|
||||
* date_from: ?string,
|
||||
* date_to: ?string
|
||||
* }
|
||||
*/
|
||||
public function platformLifetimeTotals(): array
|
||||
{
|
||||
$totalBetMinor = (int) DB::table('ticket_orders')->sum('total_actual_deduct');
|
||||
|
||||
$payoutAgg = DB::table('ticket_items')
|
||||
->selectRaw('COALESCE(SUM(win_amount), 0) as win_minor, COALESCE(SUM(jackpot_win_amount), 0) as jackpot_minor')
|
||||
->first();
|
||||
$totalPayoutMinor = (int) ($payoutAgg->win_minor ?? 0) + (int) ($payoutAgg->jackpot_minor ?? 0);
|
||||
|
||||
$activity = DB::table('draws as d')
|
||||
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
|
||||
->selectRaw('COUNT(DISTINCT d.id) as draw_count')
|
||||
->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count')
|
||||
->selectRaw('MIN(d.business_date) as date_from')
|
||||
->selectRaw('MAX(d.business_date) as date_to')
|
||||
->first();
|
||||
|
||||
$drawCount = (int) ($activity->draw_count ?? 0);
|
||||
$businessDayCount = (int) ($activity->business_day_count ?? 0);
|
||||
|
||||
$dateFrom = $this->formatBusinessDateValue($activity?->date_from);
|
||||
$dateTo = $this->formatBusinessDateValue($activity?->date_to);
|
||||
|
||||
$currencyCode = (string) (DB::table('ticket_orders')->orderByDesc('id')->value('currency_code') ?? '');
|
||||
|
||||
return [
|
||||
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
|
||||
'total_bet_minor' => $totalBetMinor,
|
||||
'total_payout_minor' => $totalPayoutMinor,
|
||||
'approx_house_gross_minor' => $totalBetMinor - $totalPayoutMinor,
|
||||
'draw_count' => $drawCount,
|
||||
'business_day_count' => $businessDayCount,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
];
|
||||
}
|
||||
|
||||
public function playerWinLossPaginated(
|
||||
?int $playerId,
|
||||
string $dateFrom,
|
||||
@@ -340,4 +565,26 @@ final class AdminReportQueryService
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function formatBusinessDateValue(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof Carbon) {
|
||||
return $value->format('Y-m-d');
|
||||
}
|
||||
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $raw, $m) === 1) {
|
||||
return substr($m[0], 0, 10);
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,7 @@ final class EffectivePlayCatalogService
|
||||
'category' => $c->category,
|
||||
'dimension' => $c->dimension === null ? null : (int) $c->dimension,
|
||||
'bet_mode' => $c->bet_mode,
|
||||
'display_name_zh' => $c->display_name_zh,
|
||||
'display_name_en' => $c->display_name_en,
|
||||
'display_name_ne' => $c->display_name_ne,
|
||||
'display_name' => $c->display_name,
|
||||
'sort_order' => (int) $c->display_order,
|
||||
'supports_multi_number' => (bool) $c->supports_multi_number,
|
||||
'master_enabled' => (bool) $c->is_enabled,
|
||||
@@ -148,9 +146,7 @@ final class EffectivePlayCatalogService
|
||||
'category' => $r->category,
|
||||
'dimension' => $r->dimension === null ? null : (int) $r->dimension,
|
||||
'bet_mode' => $r->bet_mode,
|
||||
'display_name_zh' => $r->display_name_zh,
|
||||
'display_name_en' => $r->display_name_en,
|
||||
'display_name_ne' => $r->display_name_ne,
|
||||
'display_name' => $r->display_name,
|
||||
'is_enabled' => (bool) $r->is_enabled,
|
||||
'min_bet_amount' => (int) $r->min_bet_amount,
|
||||
'max_bet_amount' => (int) $r->max_bet_amount,
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use App\Support\OddsStandardScopes;
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
@@ -162,6 +163,7 @@ final class OddsStreamService
|
||||
beforeJson: $before,
|
||||
afterJson: $after,
|
||||
);
|
||||
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
}
|
||||
|
||||
public function deleteVersion(OddsVersion $version, AdminUser $admin, ?Request $request = null): void
|
||||
@@ -182,6 +184,7 @@ final class OddsStreamService
|
||||
beforeJson: $before,
|
||||
afterJson: null,
|
||||
);
|
||||
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\PlayConfigVersion;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
@@ -62,9 +63,7 @@ final class PlayConfigStreamService
|
||||
'category' => $row->category,
|
||||
'dimension' => $row->dimension,
|
||||
'bet_mode' => $row->bet_mode,
|
||||
'display_name_zh' => $row->display_name_zh,
|
||||
'display_name_en' => $row->display_name_en,
|
||||
'display_name_ne' => $row->display_name_ne,
|
||||
'display_name' => $row->display_name,
|
||||
'is_enabled' => $row->is_enabled,
|
||||
'min_bet_amount' => $row->min_bet_amount,
|
||||
'max_bet_amount' => $row->max_bet_amount,
|
||||
@@ -85,9 +84,7 @@ final class PlayConfigStreamService
|
||||
'category' => $pt->category,
|
||||
'dimension' => $pt->dimension,
|
||||
'bet_mode' => $pt->bet_mode,
|
||||
'display_name_zh' => $pt->display_name_zh,
|
||||
'display_name_en' => $pt->display_name_en,
|
||||
'display_name_ne' => $pt->display_name_ne,
|
||||
'display_name' => $pt->display_name,
|
||||
'is_enabled' => (bool) $pt->is_enabled,
|
||||
'min_bet_amount' => 100,
|
||||
'max_bet_amount' => 500_000_000,
|
||||
@@ -109,6 +106,53 @@ final class PlayConfigStreamService
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*/
|
||||
/**
|
||||
* 即时切换当前生效玩法开关(无需发布草稿),并推送大厅 WS。
|
||||
*/
|
||||
public function patchActivePlayToggle(AdminUser $admin, string $playCode, bool $enabled, ?Request $request = null): PlayConfigItem
|
||||
{
|
||||
/** @var PlayConfigVersion $active */
|
||||
$active = PlayConfigVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->firstOrFail();
|
||||
|
||||
/** @var PlayConfigItem $item */
|
||||
$item = PlayConfigItem::query()
|
||||
->where('version_id', $active->id)
|
||||
->where('play_code', $playCode)
|
||||
->firstOrFail();
|
||||
|
||||
$before = ['is_enabled' => (bool) $item->is_enabled];
|
||||
if ($before['is_enabled'] === $enabled) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($item, $enabled, $active, $admin, $playCode): void {
|
||||
$item->forceFill(['is_enabled' => $enabled])->save();
|
||||
$active->forceFill(['updated_by' => $admin->id])->save();
|
||||
|
||||
PlayType::query()
|
||||
->where('play_code', $playCode)
|
||||
->update(['is_enabled' => $enabled]);
|
||||
});
|
||||
|
||||
$this->hallRealtime->notifyPlayToggle($playCode, $enabled, 'play toggle applied to active config');
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'play_config',
|
||||
actionCode: 'toggle_active',
|
||||
targetType: 'play_config_item',
|
||||
targetId: $playCode,
|
||||
beforeJson: $before,
|
||||
afterJson: ['is_enabled' => $enabled, 'active_version_id' => $active->id],
|
||||
);
|
||||
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return $item->refresh();
|
||||
}
|
||||
|
||||
public function replaceItems(PlayConfigVersion $draft, array $items, AdminUser $admin): void
|
||||
{
|
||||
DB::transaction(function () use ($draft, $items, $admin): void {
|
||||
@@ -121,9 +165,7 @@ final class PlayConfigStreamService
|
||||
'category' => $row['category'] ?? null,
|
||||
'dimension' => $row['dimension'] ?? null,
|
||||
'bet_mode' => $row['bet_mode'] ?? null,
|
||||
'display_name_zh' => $row['display_name_zh'] ?? null,
|
||||
'display_name_en' => $row['display_name_en'] ?? null,
|
||||
'display_name_ne' => $row['display_name_ne'] ?? null,
|
||||
'display_name' => $row['display_name'] ?? null,
|
||||
'is_enabled' => (bool) ($row['is_enabled'] ?? true),
|
||||
'min_bet_amount' => (int) ($row['min_bet_amount'] ?? 0),
|
||||
'max_bet_amount' => (int) ($row['max_bet_amount'] ?? 0),
|
||||
@@ -183,6 +225,7 @@ final class PlayConfigStreamService
|
||||
beforeJson: $before,
|
||||
afterJson: $after,
|
||||
);
|
||||
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,6 +266,7 @@ final class PlayConfigStreamService
|
||||
beforeJson: $before,
|
||||
afterJson: null,
|
||||
);
|
||||
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
@@ -278,8 +322,8 @@ final class PlayConfigStreamService
|
||||
$errors["items.$index.max_bet_amount"][] = '最大下注额不能小于最小下注额';
|
||||
}
|
||||
|
||||
if ($row->display_name_zh === null || $row->display_name_zh === '') {
|
||||
$errors["items.$index.display_name_zh"][] = '显示名称不能为空';
|
||||
if ($row->display_name === null || $row->display_name === '') {
|
||||
$errors["items.$index.display_name"][] = '显示名称不能为空';
|
||||
}
|
||||
|
||||
if ($row->display_order === null) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Http\Request;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Models\RiskCapVersion;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -129,6 +130,7 @@ final class RiskCapStreamService
|
||||
beforeJson: $before,
|
||||
afterJson: $after,
|
||||
);
|
||||
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
}
|
||||
|
||||
public function deleteVersion(RiskCapVersion $version, AdminUser $admin, ?Request $request = null): void
|
||||
@@ -149,6 +151,7 @@ final class RiskCapStreamService
|
||||
beforeJson: $before,
|
||||
afterJson: null,
|
||||
);
|
||||
$request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ use App\Models\JackpotPayoutLog;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 产品文档 §5.11.2–5.11.3:中头奖且满足阈值或连续未爆期数 → 按比例释放奖池,按注项 `total_bet_amount` 比例分配。
|
||||
* 产品文档 §5.11.2–5.11.3:中头奖且满足阈值或连续未爆期数 → 按比例/全额释放奖池,按注项 `total_bet_amount` 比例分配。
|
||||
*/
|
||||
final class JackpotBurstAllocator
|
||||
{
|
||||
@@ -36,36 +36,72 @@ final class JackpotBurstAllocator
|
||||
}
|
||||
|
||||
$trigger = $thresholdOk ? 'threshold' : ($gapOk ? 'forced_gap' : 'play_combo');
|
||||
$releaseFullPool = $trigger === 'forced_gap';
|
||||
|
||||
$winnerItems = $winners->map(fn (array $r): TicketItem => $r['item'])->values();
|
||||
|
||||
return $this->burstToWinners(
|
||||
$draw,
|
||||
$pool,
|
||||
$winnerItems,
|
||||
$trigger,
|
||||
$releaseFullPool,
|
||||
[
|
||||
'threshold_ok' => $thresholdOk,
|
||||
'gap_ok' => $gapOk,
|
||||
'combo_ok' => $comboOk,
|
||||
'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管手动爆池:跳过头奖触发条件校验,仍要求存在头奖中奖注单,并按配置派彩比例释放奖池。
|
||||
*
|
||||
* @param Collection<int, TicketItem> $winnerItems
|
||||
* @return array{allocations: array<int, int>, pool_payout: int, trigger: string, log_id: int}
|
||||
*/
|
||||
public function burstManual(Draw $draw, JackpotPool $pool, Collection $winnerItems): array
|
||||
{
|
||||
if ($winnerItems->isEmpty()) {
|
||||
throw new \RuntimeException('jackpot_manual_no_first_prize_winners');
|
||||
}
|
||||
|
||||
$out = $this->burstToWinners($draw, $pool, $winnerItems, 'manual', false, ['manual' => true]);
|
||||
|
||||
return [
|
||||
'allocations' => $out['allocations'],
|
||||
'pool_payout' => $out['pool_payout'],
|
||||
'trigger' => 'manual',
|
||||
'log_id' => (int) ($out['log_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, TicketItem> $winnerItems
|
||||
* @param array<string, mixed> $snapshotExtra
|
||||
* @return array{allocations: array<int, int>, pool_payout: int, trigger: string, log_id: int}
|
||||
*/
|
||||
private function burstToWinners(
|
||||
Draw $draw,
|
||||
JackpotPool $pool,
|
||||
Collection $winnerItems,
|
||||
string $trigger,
|
||||
bool $releaseFullPool,
|
||||
array $snapshotExtra,
|
||||
): array {
|
||||
$poolBefore = (int) $pool->current_amount;
|
||||
$poolPayout = (int) floor($poolBefore * (float) $pool->payout_rate);
|
||||
$poolPayout = $releaseFullPool
|
||||
? $poolBefore
|
||||
: (int) floor($poolBefore * (float) $pool->payout_rate);
|
||||
|
||||
if ($poolPayout <= 0) {
|
||||
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
|
||||
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0];
|
||||
}
|
||||
|
||||
$list = $winners->values()->all();
|
||||
$weightTotal = 0;
|
||||
foreach ($list as $r) {
|
||||
$weightTotal += (int) $r['item']->total_bet_amount;
|
||||
}
|
||||
if ($weightTotal <= 0) {
|
||||
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null];
|
||||
}
|
||||
|
||||
$allocations = [];
|
||||
$remaining = $poolPayout;
|
||||
$n = count($list);
|
||||
foreach ($list as $idx => $r) {
|
||||
/** @var TicketItem $item */
|
||||
$item = $r['item'];
|
||||
$w = (int) $item->total_bet_amount;
|
||||
if ($idx === $n - 1) {
|
||||
$share = max(0, $remaining);
|
||||
} else {
|
||||
$share = (int) floor($poolPayout * $w / $weightTotal);
|
||||
$remaining -= $share;
|
||||
}
|
||||
$allocations[(int) $item->id] = $share;
|
||||
$allocations = $this->distributeByBetWeight($winnerItems, $poolPayout);
|
||||
if ($allocations === []) {
|
||||
return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0];
|
||||
}
|
||||
|
||||
$pool->forceFill([
|
||||
@@ -73,23 +109,59 @@ final class JackpotBurstAllocator
|
||||
'last_trigger_draw_id' => $draw->id,
|
||||
])->save();
|
||||
|
||||
JackpotPayoutLog::query()->create([
|
||||
$log = JackpotPayoutLog::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'jackpot_pool_id' => $pool->id,
|
||||
'trigger_type' => $trigger,
|
||||
'total_payout_amount' => $poolPayout,
|
||||
'winner_count' => count($allocations),
|
||||
'trigger_snapshot_json' => [
|
||||
'threshold_ok' => $thresholdOk,
|
||||
'gap_ok' => $gapOk,
|
||||
'combo_ok' => $comboOk,
|
||||
'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool),
|
||||
'trigger_snapshot_json' => array_merge($snapshotExtra, [
|
||||
'pool_amount_before' => $poolBefore,
|
||||
'payout_rate' => (string) $pool->payout_rate,
|
||||
],
|
||||
'release_full_pool' => $releaseFullPool,
|
||||
]),
|
||||
]);
|
||||
|
||||
return ['allocations' => $allocations, 'pool_payout' => $poolPayout, 'trigger' => $trigger];
|
||||
return [
|
||||
'allocations' => $allocations,
|
||||
'pool_payout' => $poolPayout,
|
||||
'trigger' => $trigger,
|
||||
'log_id' => (int) $log->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, TicketItem> $winnerItems
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function distributeByBetWeight(Collection $winnerItems, int $poolPayout): array
|
||||
{
|
||||
$list = $winnerItems->values()->all();
|
||||
$weightTotal = 0;
|
||||
foreach ($list as $item) {
|
||||
$weightTotal += (int) $item->total_bet_amount;
|
||||
}
|
||||
if ($weightTotal <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allocations = [];
|
||||
$remaining = $poolPayout;
|
||||
$n = count($list);
|
||||
foreach ($list as $idx => $item) {
|
||||
$w = (int) $item->total_bet_amount;
|
||||
if ($idx === $n - 1) {
|
||||
$share = max(0, $remaining);
|
||||
} else {
|
||||
$share = (int) floor($poolPayout * $w / $weightTotal);
|
||||
$remaining -= $share;
|
||||
}
|
||||
if ($share > 0) {
|
||||
$allocations[(int) $item->id] = $share;
|
||||
}
|
||||
}
|
||||
|
||||
return $allocations;
|
||||
}
|
||||
|
||||
private function gapTriggerMet(JackpotPool $pool): bool
|
||||
|
||||
264
app/Services/Jackpot/JackpotManualBurstService.php
Normal file
264
app/Services/Jackpot/JackpotManualBurstService.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Jackpot;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\JackpotPayoutLog;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Models\TicketSettlementDetail;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Services\Draw\DrawResultViewService;
|
||||
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
||||
use App\Services\Ticket\TicketWalletService;
|
||||
|
||||
/**
|
||||
* 产品文档:超管紧急手动爆池 —— 对已结算期号的头奖中奖者按奖池派彩比例分配,合并入账并广播动画。
|
||||
*/
|
||||
final class JackpotManualBurstService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JackpotBurstAllocator $allocator,
|
||||
private readonly TicketWalletService $wallet,
|
||||
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
|
||||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||||
private readonly DrawResultViewService $drawResults,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* current_amount: int,
|
||||
* burst_amount: int,
|
||||
* log_id: int|null,
|
||||
* winner_count: int,
|
||||
* draw_no: string,
|
||||
* wallet_credited: bool
|
||||
* }
|
||||
*/
|
||||
public function execute(JackpotPool $pool, int $drawId): array
|
||||
{
|
||||
return DB::transaction(function () use ($pool, $drawId): array {
|
||||
/** @var JackpotPool $locked */
|
||||
$locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if ((int) $locked->status !== 1) {
|
||||
throw new \RuntimeException('jackpot_disabled');
|
||||
}
|
||||
|
||||
if ((int) $locked->current_amount <= 0) {
|
||||
throw new \RuntimeException('jackpot_pool_empty');
|
||||
}
|
||||
|
||||
$draw = Draw::query()->whereKey($drawId)->firstOrFail();
|
||||
$this->assertDrawReady($draw);
|
||||
|
||||
if (JackpotPayoutLog::query()
|
||||
->where('jackpot_pool_id', $locked->id)
|
||||
->where('draw_id', $drawId)
|
||||
->exists()) {
|
||||
throw new \RuntimeException('jackpot_already_burst_for_draw');
|
||||
}
|
||||
|
||||
$batch = $this->resolveSettlementBatch($draw);
|
||||
$winnerItems = $this->firstPrizeWinnerItems($batch);
|
||||
if ($winnerItems->isEmpty()) {
|
||||
throw new \RuntimeException('jackpot_manual_no_first_prize_winners');
|
||||
}
|
||||
|
||||
$existingJackpot = (int) $batch->total_jackpot_payout_amount;
|
||||
if ($existingJackpot > 0) {
|
||||
throw new \RuntimeException('jackpot_already_allocated_for_draw');
|
||||
}
|
||||
|
||||
$burst = $this->allocator->burstManual($draw, $locked, $winnerItems);
|
||||
$poolPayout = (int) $burst['pool_payout'];
|
||||
if ($poolPayout <= 0) {
|
||||
return [
|
||||
'current_amount' => (int) $locked->current_amount,
|
||||
'burst_amount' => 0,
|
||||
'log_id' => null,
|
||||
'winner_count' => 0,
|
||||
'draw_no' => (string) $draw->draw_no,
|
||||
'wallet_credited' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$allocations = $burst['allocations'];
|
||||
$this->applyAllocationsToSettlement($batch, $allocations);
|
||||
|
||||
$walletCredited = $this->creditWalletsIfAlreadyPaid($batch, $allocations, (int) $burst['log_id'], $locked->currency_code);
|
||||
|
||||
$locked->refresh();
|
||||
|
||||
$firstPrizeNumber = $this->drawResults->firstPrizeNumber4dForDraw($draw);
|
||||
if ($firstPrizeNumber === '') {
|
||||
$firstPrizeNumber = '----';
|
||||
}
|
||||
|
||||
$this->hallRealtime->notifyJackpotBurst(
|
||||
(int) $draw->id,
|
||||
(string) $draw->draw_no,
|
||||
$firstPrizeNumber,
|
||||
(string) $locked->currency_code,
|
||||
$poolPayout,
|
||||
count($allocations),
|
||||
'manual',
|
||||
(int) $locked->current_amount,
|
||||
);
|
||||
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
|
||||
|
||||
return [
|
||||
'current_amount' => (int) $locked->current_amount,
|
||||
'burst_amount' => $poolPayout,
|
||||
'log_id' => (int) $burst['log_id'],
|
||||
'winner_count' => count($allocations),
|
||||
'draw_no' => (string) $draw->draw_no,
|
||||
'wallet_credited' => $walletCredited,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function assertDrawReady(Draw $draw): void
|
||||
{
|
||||
$allowed = [
|
||||
DrawStatus::Settling->value,
|
||||
DrawStatus::Settled->value,
|
||||
];
|
||||
if (! in_array($draw->status, $allowed, true)) {
|
||||
throw new \RuntimeException('draw_not_ready_for_jackpot_burst');
|
||||
}
|
||||
|
||||
$hasPublished = DrawResultBatch::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->where('status', DrawResultBatchStatus::Published->value)
|
||||
->where('result_version', (int) $draw->current_result_version)
|
||||
->exists();
|
||||
|
||||
if (! $hasPublished) {
|
||||
throw new \RuntimeException('draw_result_not_published');
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveSettlementBatch(Draw $draw): SettlementBatch
|
||||
{
|
||||
$batch = SettlementBatch::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->whereIn('status', [
|
||||
SettlementBatchStatus::PendingReview->value,
|
||||
SettlementBatchStatus::Approved->value,
|
||||
SettlementBatchStatus::Paid->value,
|
||||
SettlementBatchStatus::Completed->value,
|
||||
])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($batch === null) {
|
||||
throw new \RuntimeException('settlement_batch_not_found');
|
||||
}
|
||||
|
||||
return $batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TicketItem>
|
||||
*/
|
||||
private function firstPrizeWinnerItems(SettlementBatch $batch): Collection
|
||||
{
|
||||
$details = TicketSettlementDetail::query()
|
||||
->where('settlement_batch_id', $batch->id)
|
||||
->where('matched_prize_tier', 'first')
|
||||
->where('win_amount', '>', 0)
|
||||
->with('ticketItem')
|
||||
->get();
|
||||
|
||||
return $details
|
||||
->map(fn (TicketSettlementDetail $d) => $d->ticketItem)
|
||||
->filter(fn (?TicketItem $item): bool => $item instanceof TicketItem)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $allocations
|
||||
*/
|
||||
private function applyAllocationsToSettlement(SettlementBatch $batch, array $allocations): void
|
||||
{
|
||||
$addedJackpot = 0;
|
||||
|
||||
foreach ($allocations as $ticketItemId => $share) {
|
||||
$detail = TicketSettlementDetail::query()
|
||||
->where('settlement_batch_id', $batch->id)
|
||||
->where('ticket_item_id', $ticketItemId)
|
||||
->first();
|
||||
|
||||
if ($detail === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$detail->forceFill(['jackpot_allocation_amount' => $share])->save();
|
||||
|
||||
$item = $detail->ticketItem;
|
||||
if ($item !== null) {
|
||||
$item->forceFill(['jackpot_win_amount' => $share])->save();
|
||||
}
|
||||
|
||||
$addedJackpot += $share;
|
||||
}
|
||||
|
||||
if ($addedJackpot > 0) {
|
||||
$batch->forceFill([
|
||||
'total_jackpot_payout_amount' => (int) $batch->total_jackpot_payout_amount + $addedJackpot,
|
||||
'total_payout_amount' => (int) $batch->total_payout_amount + $addedJackpot,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 若结算批次已派彩,则补发 Jackpot 份额到玩家钱包。
|
||||
*
|
||||
* @param array<int, int> $allocations
|
||||
*/
|
||||
private function creditWalletsIfAlreadyPaid(
|
||||
SettlementBatch $batch,
|
||||
array $allocations,
|
||||
int $jackpotLogId,
|
||||
string $currencyCode,
|
||||
): bool {
|
||||
if (! in_array($batch->status, [SettlementBatchStatus::Paid->value, SettlementBatchStatus::Completed->value], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$playerTotals = [];
|
||||
foreach ($allocations as $ticketItemId => $share) {
|
||||
if ($share <= 0) {
|
||||
continue;
|
||||
}
|
||||
$item = TicketItem::query()->whereKey($ticketItemId)->first();
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
$pid = (int) $item->player_id;
|
||||
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $share;
|
||||
}
|
||||
|
||||
foreach ($playerTotals as $playerId => $amount) {
|
||||
$player = Player::query()->whereKey($playerId)->firstOrFail();
|
||||
$this->wallet->creditJackpotManualPayout(
|
||||
$player,
|
||||
$currencyCode,
|
||||
$amount,
|
||||
(int) $batch->id,
|
||||
$jackpotLogId,
|
||||
);
|
||||
}
|
||||
|
||||
return $playerTotals !== [];
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,17 @@ final class SettlementBatchWorkflowService
|
||||
|
||||
public function approve(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch
|
||||
{
|
||||
return DB::transaction(function () use ($batch, $admin, $remark): SettlementBatch {
|
||||
return $this->approveInternal($batch, $admin->id, $remark);
|
||||
}
|
||||
|
||||
public function approveBySystem(SettlementBatch $batch, ?string $remark = null): SettlementBatch
|
||||
{
|
||||
return $this->approveInternal($batch, null, $remark);
|
||||
}
|
||||
|
||||
private function approveInternal(SettlementBatch $batch, ?int $reviewedBy, ?string $remark): SettlementBatch
|
||||
{
|
||||
return DB::transaction(function () use ($batch, $reviewedBy, $remark): SettlementBatch {
|
||||
/** @var SettlementBatch $locked */
|
||||
$locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->status !== SettlementBatchStatus::PendingReview->value) {
|
||||
@@ -31,7 +41,7 @@ final class SettlementBatchWorkflowService
|
||||
$locked->forceFill([
|
||||
'status' => SettlementBatchStatus::Approved->value,
|
||||
'review_status' => 'approved',
|
||||
'reviewed_by' => $admin->id,
|
||||
'reviewed_by' => $reviewedBy,
|
||||
'reviewed_at' => now(),
|
||||
'review_remark' => $remark,
|
||||
])->save();
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Models\TicketSettlementDetail;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
use App\Services\Jackpot\JackpotBurstAllocator;
|
||||
@@ -30,6 +31,7 @@ final class SettlementOrchestrator
|
||||
private readonly JackpotBurstAllocator $jackpotBurst,
|
||||
private readonly RiskPoolService $riskPool,
|
||||
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
|
||||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -214,6 +216,7 @@ final class SettlementOrchestrator
|
||||
$jackpotTrigger,
|
||||
(int) $jackpotPoolAfter,
|
||||
);
|
||||
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
65
app/Services/Settlement/SettlementTickFinalizer.php
Normal file
65
app/Services/Settlement/SettlementTickFinalizer.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Settlement;
|
||||
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Services\LotterySettings;
|
||||
|
||||
/**
|
||||
* draw tick 在自动结算后,按系统设置自动审核并派彩入账。
|
||||
*/
|
||||
final class SettlementTickFinalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettlementBatchWorkflowService $workflow,
|
||||
) {}
|
||||
|
||||
/** @return array{approved: int, paid: int} */
|
||||
public function finalizePendingBatches(): array
|
||||
{
|
||||
$approved = 0;
|
||||
$paid = 0;
|
||||
|
||||
if (! (bool) LotterySettings::get('settlement.auto_approve_on_tick', true)) {
|
||||
return ['approved' => 0, 'paid' => 0];
|
||||
}
|
||||
|
||||
$pending = SettlementBatch::query()
|
||||
->where('status', SettlementBatchStatus::PendingReview->value)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($pending as $batch) {
|
||||
try {
|
||||
$this->workflow->approveBySystem($batch, 'auto approve on draw tick');
|
||||
$approved++;
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->workflow->payout($batch->fresh());
|
||||
$paid++;
|
||||
AuditLogger::recordForSystem(
|
||||
moduleCode: 'settlement',
|
||||
actionCode: 'auto_payout',
|
||||
targetType: 'settlement_batch',
|
||||
targetId: (string) $batch->id,
|
||||
afterJson: ['draw_id' => (int) $batch->draw_id],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return ['approved' => $approved, 'paid' => $paid];
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,71 @@ final class TicketWalletService
|
||||
/**
|
||||
* 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。
|
||||
*/
|
||||
/**
|
||||
* 手动爆池补发:结算批次已派彩后,将 Jackpot 份额追加入账(幂等键按批次 + 玩家 + 爆池日志)。
|
||||
*/
|
||||
public function creditJackpotManualPayout(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
int $settlementBatchId,
|
||||
int $jackpotPayoutLogId,
|
||||
): void {
|
||||
if ($amountMinor <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$idempotentKey = 'jackpot-manual:'.$settlementBatchId.':'.$player->id.':'.$jackpotPayoutLogId;
|
||||
if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currency = strtoupper($currencyCode);
|
||||
|
||||
$wallet = PlayerWallet::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('wallet_type', 'lottery')
|
||||
->where('currency_code', $currency)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($wallet === null) {
|
||||
$wallet = PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => $currency,
|
||||
'balance' => 0,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
$before = (int) $wallet->balance;
|
||||
$after = $before + $amountMinor;
|
||||
$wallet->forceFill([
|
||||
'balance' => $after,
|
||||
'version' => (int) $wallet->version + 1,
|
||||
])->save();
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => $this->newTxnNo(),
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => 'jackpot_manual_payout',
|
||||
'biz_no' => 'JP'.$jackpotPayoutLogId,
|
||||
'direction' => self::TXN_DIR_IN,
|
||||
'amount' => $amountMinor,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'status' => self::TXN_POSTED,
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'remark' => 'manual_jackpot_burst',
|
||||
]);
|
||||
}
|
||||
|
||||
public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
|
||||
Reference in New Issue
Block a user