Files
lotteryLaravel/app/Services/Settlement/SettlementOrchestrator.php
kang f0e0966a73
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix(lottery): 增加广播任务时效保护并强化命令异常处理
- 为多个广播事件添加 retryUntil 方法,设置派发后 30 秒内必须执行,否则丢弃
- 修改 LotteryDrawTickCommand handle,添加异常捕获及错误日志,保证失败时返回 FAILURE
- 修改 LotteryHallCountdownCommand handle,添加异常捕获及错误日志,超时情况写入警告日志
- 针对 SettlementOrchestrator 和 DrawHallSnapshotBuilder 查询结果添加限制,防止数据量过大
- 调整计划任务 withoutOverlapping 调用,增加 expiresAt 参数避免任务锁死
- 更新 .env.example,完善本地开发与生产环境部署说明及日志配置说明
- 移除 e2e 相关测试代码、配置及依赖,精简项目体积
2026-07-07 13:37:05 +08:00

276 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Settlement;
use App\Models\Draw;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
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;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
/**
* 阶段 6对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 待审核)。
*
* 派彩入账由审核通过后的独立 payout 动作执行,避免未确认结果直接入账。
*/
final class SettlementOrchestrator
{
public function __construct(
private readonly SettlementMatcherRegistry $matchers,
private readonly SettlementPayoutAdjuster $payoutAdjuster,
private readonly JackpotBurstAllocator $jackpotBurst,
private readonly RiskPoolService $riskPool,
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly AgentGameSettlementRecorder $agentGameSettlement,
) {}
/**
* @return bool true 表示已处理(新结算或补全期号状态)
*/
public function trySettleDraw(Draw $draw): bool
{
$afterCommit = DB::transaction(function () use ($draw): array {
/** @var Draw $locked */
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
if ($locked->status === DrawStatus::Settled->value) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
if ($locked->status !== DrawStatus::Settling->value) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$publishedBatch = DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value)
->where('result_version', (int) $locked->current_result_version)
->orderByDesc('id')
->first();
if ($publishedBatch === null) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$existingDone = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->first();
if ($existingDone !== null) {
$locked->forceFill([
'settle_version' => (int) $existingDone->settle_version,
])->save();
return [
'handled' => true,
'jackpot_bursts' => [],
'should_notify_status' => true,
];
}
$items = DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
->orderBy('id')
->get();
$board = new PublishedDrawResultBoard($items);
$nextSettleVersion = (int) $locked->settle_version + 1;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
->with(['combinations', 'order'])
->orderBy('id')
->limit(10_000)
->get();
if ($ticketItems->count() >= 10_000) {
\Illuminate\Support\Facades\Log::warning('SettlementOrchestrator: ticket_items hit safety cap', [
'draw_id' => $locked->id,
'draw_no' => $locked->draw_no,
'loaded_count' => $ticketItems->count(),
]);
}
/** @var list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = [];
foreach ($ticketItems as $item) {
$matcher = $this->matchers->for((string) $item->play_code);
$result = $matcher->match($item, $board, $item->combinations);
$gross = max(0, (int) $result['win_amount']);
$tier = $result['matched_prize_tier'] ?? null;
$tier = is_string($tier) ? $tier : null;
$net = $this->payoutAdjuster->adjustGrossWin($gross, $item);
$prepared[] = [
'item' => $item,
'gross_win' => $gross,
'matched_tier' => $tier,
'net_win' => $net,
'match_detail' => $result['match_detail'],
];
}
$allocations = [];
$totalJackpotPayout = 0;
$jackpotBursts = [];
$preparedByCurrency = collect($prepared)->groupBy(
fn (array $p): string => strtoupper((string) ($p['item']->order?->currency_code ?? 'NPR')),
);
foreach ($preparedByCurrency as $currency => $currencyPrepared) {
$pool = JackpotPool::query()
->where('currency_code', $currency)
->where('status', 1)
->lockForUpdate()
->first();
if ($pool === null) {
continue;
}
$burstInput = collect($currencyPrepared)->map(fn (array $p): array => [
'item' => $p['item'],
'matched_tier' => $p['matched_tier'],
'gross_win' => $p['gross_win'],
]);
$burstOut = $this->jackpotBurst->allocate($locked, $pool, $burstInput);
$allocations = array_replace($allocations, $burstOut['allocations']);
$currencyPayout = (int) $burstOut['pool_payout'];
$totalJackpotPayout += $currencyPayout;
if ($currencyPayout > 0 && is_string($burstOut['trigger'])) {
$jackpotBursts[] = [
'currency' => $currency,
'payout' => $currencyPayout,
'trigger' => $burstOut['trigger'],
'pool_after' => (int) $pool->fresh()->current_amount,
'winner_count' => count($burstOut['allocations']),
];
}
}
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
foreach ($prepared as $p) {
/** @var TicketItem $item */
$item = $p['item'];
$ticketCount++;
$net = (int) $p['net_win'];
$jackpotShare = (int) ($allocations[(int) $item->id] ?? 0);
$finalCredit = $net + $jackpotShare;
TicketSettlementDetail::query()->create([
'settlement_batch_id' => $batchRow->id,
'ticket_item_id' => $item->id,
'matched_prize_tier' => $p['matched_tier'],
'win_amount' => $net,
'jackpot_allocation_amount' => $jackpotShare,
'match_detail_json' => $p['match_detail'],
]);
$terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose';
$item->forceFill([
'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare,
'settled_at' => null,
'status' => $terminalStatus,
])->save();
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus);
if ($finalCredit > 0) {
$winCount++;
}
$totalPayout += $finalCredit;
$locks = [];
foreach ($item->combinations as $c) {
$locks[] = [
'number_4d' => (string) $c->number_4d,
'amount' => (int) $c->estimated_payout,
];
}
$this->riskPool->release((int) $locked->id, $item, $locks);
}
$batchRow->forceFill([
'status' => SettlementBatchStatus::PendingReview->value,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(),
])->save();
$locked->forceFill([
'status' => DrawStatus::Settling->value,
'settle_version' => $nextSettleVersion,
])->save();
return [
'handled' => true,
'jackpot_bursts' => array_map(fn (array $burst): array => [
'draw_id' => (int) $locked->id,
'draw_no' => (string) $locked->draw_no,
'first_prize_number' => $board->firstPrizeNumber4d(),
'currency' => (string) $burst['currency'],
'payout' => (int) $burst['payout'],
'winner_count' => (int) $burst['winner_count'],
'trigger' => (string) $burst['trigger'],
'pool_after' => (int) $burst['pool_after'],
], $jackpotBursts),
'should_notify_status' => true,
];
});
foreach ($afterCommit['jackpot_bursts'] as $burst) {
$this->hallRealtime->notifyJackpotBurst(
$burst['draw_id'],
$burst['draw_no'],
$burst['first_prize_number'],
$burst['currency'],
$burst['payout'],
$burst['winner_count'],
$burst['trigger'],
$burst['pool_after'],
);
}
if (($afterCommit['should_notify_status'] ?? false) === true) {
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
}
return (bool) ($afterCommit['handled'] ?? false);
}
}