feat: enhance configuration and logging for admin services
Some checks failed
lotterLaravel CI / test (push) Has been cancelled

- Updated .env.example to enable Redis Lua for lottery risk pool and added configuration for agent settlement.
- Improved AGENTS.md to clarify site operations and admin roles.
- Enhanced PHPUnit configuration with memory limit and cache directory settings.
- Refactored AdminReportQueryService and related services to utilize LimitedQuery for better data handling and truncation warnings.
- Added logging for draw settlement failures in DrawTickService.
- Introduced credit preflight checks and reverse bet hold functionality in PlayerCreditService.
- Improved risk pool management with new Redis lock handling in RiskPoolService and TicketPlacementService.
This commit is contained in:
2026-06-16 17:08:10 +08:00
parent 4e370a79dc
commit b496a9457c
16 changed files with 423 additions and 494 deletions

View File

@@ -148,30 +148,7 @@ final class RiskPoolService
foreach ($locks as $lock) {
$number4d = $lock['number_4d'];
$amount = (int) $lock['amount'];
$pool = $this->firstOrMakePool($drawId, $number4d);
$key = $this->redisPoolKey($drawId, $number4d);
Redis::eval(
$this->initLua(),
1,
$key,
(int) $pool->total_cap_amount,
(int) $pool->locked_amount,
(int) $pool->version,
$this->redisPoolTtlSeconds(),
);
$result = $this->normalizeLuaResult(Redis::eval(
$this->acquireLua(),
1,
$key,
$amount,
(int) $pool->version,
$this->redisPoolTtlSeconds(),
));
if (($result['code'] ?? null) !== 'OK') {
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
}
$this->acquireRedisLockForCombination($drawId, $number4d, $amount);
$acquired[] = ['number_4d' => $number4d, 'amount' => $amount];
$total += $amount;
@@ -188,6 +165,20 @@ final class RiskPoolService
return $total;
}
/**
* DB 事务回滚时补偿 Redis 侧已占用额度DB 锁行会随事务回滚)。
*
* @param list<array{number_4d:string, amount:int}> $locks
*/
public function compensateRedisAcquires(int $drawId, array $locks): void
{
if ($locks === [] || ! $this->shouldUseRedisAtomicLocks()) {
return;
}
$this->releaseRedisLocks($drawId, $locks);
}
/**
* @param list<array{number_4d:string, amount:int}> $locks
*/
@@ -202,6 +193,66 @@ final class RiskPoolService
}
}
private function acquireRedisLockForCombination(int $drawId, string $number4d, int $amount): void
{
for ($attempt = 0; $attempt < 2; $attempt++) {
$pool = $this->firstOrMakePool($drawId, $number4d);
$key = $this->redisPoolKey($drawId, $number4d);
Redis::eval(
$this->initLua(),
1,
$key,
(int) $pool->total_cap_amount,
(int) $pool->locked_amount,
(int) $pool->version,
$this->redisPoolTtlSeconds(),
);
$result = $this->normalizeLuaResult(Redis::eval(
$this->acquireLua(),
1,
$key,
$amount,
(int) $pool->version,
$this->redisPoolTtlSeconds(),
));
if (($result['code'] ?? null) === 'OK') {
return;
}
if (($result['code'] ?? null) === 'INSUFFICIENT_CAP') {
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
}
if ($attempt === 0 && in_array($result['code'] ?? '', ['VERSION_CONFLICT', 'POOL_NOT_INITIALIZED'], true)) {
$freshPool = RiskPool::query()
->where('draw_id', $drawId)
->where('normalized_number', $number4d)
->firstOrFail();
$this->syncRedisStateFromPool($freshPool);
continue;
}
$this->throwForRedisAcquireFailure($result);
}
}
/**
* @param array{code:string, remaining:int, locked:int, version:int} $result
*/
private function throwForRedisAcquireFailure(array $result): void
{
throw new TicketOperationException(
'risk_pool_unavailable',
ErrorCode::InternalError->value,
503,
['redis_code' => $result['code'] ?? 'unknown'],
);
}
public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void
{
$this->riskRealtime->publishManualSoldOut($draw, $normalizedNumber);

View File

@@ -72,6 +72,8 @@ final class TicketPlacementService
}
try {
$riskRedisCompensation = [];
$placement = DB::transaction(function () use (
$player,
$currencyCode,
@@ -79,7 +81,17 @@ final class TicketPlacementService
$expectedVersions,
$clientTraceId,
$drawNo,
&$riskRedisCompensation,
): array {
DB::afterRollback(function () use (&$riskRedisCompensation): void {
foreach ($riskRedisCompensation as $entry) {
$this->riskPoolService->compensateRedisAcquires(
(int) $entry['draw_id'],
$entry['locks'],
);
}
});
$draw = Draw::query()
->where('draw_no', $drawNo)
->lockForUpdate()
@@ -162,7 +174,9 @@ final class TicketPlacementService
$creditLine = PlayerFundingMode::usesCredit($player);
if (! $creditLine) {
if ($creditLine) {
$this->playerCreditService->assertCreditPreflight($player, $totalActualDeduct);
} else {
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
->where('wallet_type', 'lottery')
@@ -295,6 +309,10 @@ final class TicketPlacementService
$successTotalRebate += $rebateAmount;
$successTotalActualDeduct += (int) $evaluated['actual_deduct_amount'];
$successTotalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
$riskRedisCompensation[] = [
'draw_id' => (int) $draw->id,
'locks' => $locks,
];
}
if ($successfulItems === []) {
@@ -370,7 +388,7 @@ final class TicketPlacementService
])->save();
});
} catch (\Throwable $e) {
DB::transaction(function () use ($order, $player): void {
DB::transaction(function () use ($order, $player, $placement): void {
$items = TicketItem::query()
->where('order_id', $order->id)
->where('status', 'pending_confirm')
@@ -401,6 +419,11 @@ final class TicketPlacementService
if (! PlayerFundingMode::usesCredit($player)) {
$this->ticketWalletService->reverseBetDeduct($order);
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
} else {
$this->playerCreditService->reverseBetHold(
$player,
(int) $placement['success_total_actual_deduct'],
);
}
});