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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

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

View File

@@ -10,7 +10,7 @@ use App\Models\JackpotPayoutLog;
use Illuminate\Support\Collection;
/**
* 产品文档 §5.11.25.11.3:中头奖且满足阈值或连续未爆期数 按比例释放奖池,按注项 `total_bet_amount` 比例分配。
* 产品文档 §5.11.25.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

View 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 !== [];
}
}

View File

@@ -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();

View File

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

View 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];
}
}

View File

@@ -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) {