feat: 增强奖池与钱包服务的多币种支持能力

更新 JackpotManualBurstService:在解析头奖中奖者时支持币种代码处理。
重构 SettlementBatchWorkflowService:按币种聚合玩家派奖金额,确保各币种结算准确入账。
修改 SettlementOrchestrator:按币种分别处理奖池爆奖流程,提升派奖准确性。
优化 TicketWalletService:在派奖幂等键中加入币种信息,避免多币种场景下的重复处理问题。
新增测试用例,验证多币种派奖场景及待处理交易的正确处理逻辑。
This commit is contained in:
2026-05-28 16:50:24 +08:00
parent 8ccf39dff5
commit 0323d92381
8 changed files with 857 additions and 73 deletions

View File

@@ -68,7 +68,7 @@ final class JackpotManualBurstService
}
$batch = $this->resolveSettlementBatch($draw);
$winnerItems = $this->firstPrizeWinnerItems($batch);
$winnerItems = $this->firstPrizeWinnerItems($batch, (string) $locked->currency_code);
if ($winnerItems->isEmpty()) {
throw new \RuntimeException('jackpot_manual_no_first_prize_winners');
}
@@ -170,17 +170,19 @@ final class JackpotManualBurstService
/**
* @return Collection<int, TicketItem>
*/
private function firstPrizeWinnerItems(SettlementBatch $batch): Collection
private function firstPrizeWinnerItems(SettlementBatch $batch, string $currencyCode): Collection
{
$targetCurrency = strtoupper($currencyCode);
$details = TicketSettlementDetail::query()
->where('settlement_batch_id', $batch->id)
->where('matched_prize_tier', 'first')
->where('win_amount', '>', 0)
->whereHas('ticketItem.order', fn ($q) => $q->where('currency_code', $targetCurrency))
->with('ticketItem')
->get();
return $details
->map(fn (TicketSettlementDetail $d) => $d->ticketItem)
->map(fn (TicketSettlementDetail $d): ?TicketItem => $d->ticketItem)
->filter(fn (?TicketItem $item): bool => $item instanceof TicketItem)
->values();
}

View File

@@ -121,8 +121,7 @@ final class SettlementBatchWorkflowService
}
$details = $locked->details()->with(['ticketItem.order'])->get();
$playerTotals = [];
$currencyByPlayer = [];
$playerCurrencyTotals = [];
foreach ($details as $detail) {
$item = $detail->ticketItem;
@@ -132,20 +131,34 @@ final class SettlementBatchWorkflowService
$finalCredit = (int) $detail->win_amount + (int) $detail->jackpot_allocation_amount;
if ($finalCredit > 0) {
$pid = (int) $item->player_id;
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit;
$currencyByPlayer[$pid] = strtoupper((string) ($item->order?->currency_code ?? 'NPR'));
$currency = strtoupper((string) ($item->order?->currency_code ?? 'NPR'));
$aggregateKey = $pid.':'.$currency;
if (! isset($playerCurrencyTotals[$aggregateKey])) {
$playerCurrencyTotals[$aggregateKey] = [
'player_id' => $pid,
'currency_code' => $currency,
'amount' => 0,
];
}
$playerCurrencyTotals[$aggregateKey]['amount'] += $finalCredit;
$item->forceFill(['status' => 'settled_win', 'settled_at' => now()])->save();
} elseif ($item->status !== 'settled_lose') {
$item->forceFill(['status' => 'settled_lose', 'settled_at' => now()])->save();
}
}
foreach ($playerTotals as $playerId => $amount) {
foreach ($playerCurrencyTotals as $entry) {
$amount = (int) $entry['amount'];
if ($amount <= 0) {
continue;
}
$player = Player::query()->whereKey($playerId)->firstOrFail();
$this->wallet->creditSettlementPayout($player, $currencyByPlayer[$playerId] ?? 'NPR', $amount, (int) $locked->id);
$player = Player::query()->whereKey((int) $entry['player_id'])->firstOrFail();
$this->wallet->creditSettlementPayout(
$player,
(string) $entry['currency_code'],
$amount,
(int) $locked->id
);
}
$orderIds = TicketItem::query()
@@ -184,23 +197,35 @@ final class SettlementBatchWorkflowService
return;
}
$orderId = TicketItem::query()->where('draw_id', $batch->draw_id)->value('order_id');
$currencyCode = strtoupper((string) (TicketOrder::query()
->whereKey($orderId)
->value('currency_code') ?? 'NPR'));
$details = $batch->details()->with(['ticketItem.order'])->get();
$restoreByCurrency = [];
foreach ($details as $detail) {
$amount = (int) $detail->jackpot_allocation_amount;
if ($amount <= 0) {
continue;
}
$currency = strtoupper((string) ($detail->ticketItem?->order?->currency_code ?? 'NPR'));
$restoreByCurrency[$currency] = ($restoreByCurrency[$currency] ?? 0) + $amount;
}
$pool = JackpotPool::query()
->where('currency_code', $currencyCode)
->where('status', 1)
->lockForUpdate()
->first();
if ($pool === null) {
if ($restoreByCurrency === []) {
return;
}
$pool->forceFill([
'current_amount' => (int) $pool->current_amount + $restoreAmount,
])->save();
foreach ($restoreByCurrency as $currency => $amount) {
$pool = JackpotPool::query()
->where('currency_code', $currency)
->where('status', 1)
->lockForUpdate()
->first();
if ($pool === null) {
continue;
}
$pool->forceFill([
'current_amount' => (int) $pool->current_amount + (int) $amount,
])->save();
}
}
}

View File

@@ -125,28 +125,41 @@ final class SettlementOrchestrator
];
}
$currency = strtoupper((string) ($ticketItems->first()?->order?->currency_code ?? 'NPR'));
$pool = JackpotPool::query()
->where('currency_code', $currency)
->where('status', 1)
->lockForUpdate()
->first();
$allocations = [];
$totalJackpotPayout = 0;
$jackpotTrigger = null;
$jackpotPoolAfter = null;
if ($pool !== null) {
$burstInput = collect($prepared)->map(fn (array $p): array => [
$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 = $burstOut['allocations'];
$totalJackpotPayout = (int) $burstOut['pool_payout'];
$jackpotTrigger = $burstOut['trigger'];
$jackpotPoolAfter = (int) $pool->fresh()->current_amount;
$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;
@@ -206,16 +219,16 @@ final class SettlementOrchestrator
'settle_version' => $nextSettleVersion,
])->save();
if ($pool !== null && $totalJackpotPayout > 0 && is_string($jackpotTrigger)) {
foreach ($jackpotBursts as $burst) {
$this->hallRealtime->notifyJackpotBurst(
(int) $locked->id,
(string) $locked->draw_no,
$board->firstPrizeNumber4d(),
$currency,
$totalJackpotPayout,
count($allocations),
$jackpotTrigger,
(int) $jackpotPoolAfter,
(string) $burst['currency'],
(int) $burst['payout'],
(int) $burst['winner_count'],
(string) $burst['trigger'],
(int) $burst['pool_after'],
);
$this->hallRealtime->notifyStatusChange($this->hallSnapshot->build());
}

View File

@@ -209,7 +209,7 @@ final class TicketWalletService
}
$currency = strtoupper($currencyCode);
$idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id;
$idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id.':'.$currency;
if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->where('biz_type', 'settle_payout')->exists()) {
return;
}