feat: enhance configuration and logging for admin services
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
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:
@@ -620,7 +620,8 @@ final class AdminReportQueryService
|
||||
$q->whereDate('created_at', '>=', $dateFrom)
|
||||
->whereDate('created_at', '<=', $dateTo);
|
||||
|
||||
foreach ($q->limit(5000)->get() as $log) {
|
||||
$limited = \App\Support\LimitedQuery::get($q, 5000);
|
||||
foreach ($limited['rows'] as $log) {
|
||||
$rows[] = [
|
||||
(int) $log->id,
|
||||
(string) $log->operator_type,
|
||||
@@ -632,6 +633,10 @@ final class AdminReportQueryService
|
||||
];
|
||||
}
|
||||
|
||||
if ($limited['truncated']) {
|
||||
$rows[] = ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
@@ -782,6 +787,10 @@ final class AdminReportQueryService
|
||||
];
|
||||
}
|
||||
|
||||
if ($limited['truncated']) {
|
||||
array_unshift($rows, ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\AdminUser;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\AgentSettlementPeriodWindow;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\LimitedQuery;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -53,7 +54,9 @@ final class SettlementCenterLedgerService
|
||||
$periodId = $filters->settlementPeriodId;
|
||||
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||
$settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||
$playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||
$playerBills = $playerBillsResult['map'];
|
||||
$billsTruncated = $playerBillsResult['truncated'];
|
||||
|
||||
$stubQueries = [];
|
||||
if ($this->shouldIncludeLedgerStub($filters, 'credit')) {
|
||||
@@ -130,6 +133,7 @@ final class SettlementCenterLedgerService
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'ledger_source' => 'settlement_ledger',
|
||||
'truncated' => $billsTruncated || $total > 5000,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -153,8 +157,12 @@ final class SettlementCenterLedgerService
|
||||
): array {
|
||||
$periodId = $filters->settlementPeriodId;
|
||||
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||
$rows = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters);
|
||||
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||
$fetched = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters);
|
||||
$rows = $fetched['rows'];
|
||||
$sourceTruncated = $fetched['truncated'];
|
||||
$playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||
$playerBills = $playerBillsResult['map'];
|
||||
$billsTruncated = $playerBillsResult['truncated'];
|
||||
|
||||
$ticketIds = [];
|
||||
foreach ($rows as $row) {
|
||||
@@ -207,6 +215,7 @@ final class SettlementCenterLedgerService
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'ledger_source' => 'credit_ledger',
|
||||
'truncated' => $sourceTruncated || $billsTruncated,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -811,7 +820,7 @@ final class SettlementCenterLedgerService
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
* @return array{map: array<int, object>, truncated: bool}
|
||||
*/
|
||||
private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
|
||||
{
|
||||
@@ -841,8 +850,9 @@ final class SettlementCenterLedgerService
|
||||
|
||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||
|
||||
$limited = LimitedQuery::get($query, 500);
|
||||
$map = [];
|
||||
foreach ($query->limit(500)->get() as $bill) {
|
||||
foreach ($limited['rows'] as $bill) {
|
||||
$pid = (int) $bill->player_id;
|
||||
if (! isset($map[$pid])) {
|
||||
$map[$pid] = $bill;
|
||||
@@ -859,7 +869,10 @@ final class SettlementCenterLedgerService
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
return [
|
||||
'map' => $map,
|
||||
'truncated' => $limited['truncated'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,7 +934,7 @@ final class SettlementCenterLedgerService
|
||||
|
||||
/**
|
||||
* @param array{0: Carbon, 1: Carbon}|null $range
|
||||
* @return list<object>
|
||||
* @return array{rows: list<object>, truncated: bool}
|
||||
*/
|
||||
private function fetchBetFlowCreditRows(
|
||||
AdminUser $admin,
|
||||
@@ -975,7 +988,12 @@ final class SettlementCenterLedgerService
|
||||
$query->whereBetween('cl.created_at', $range);
|
||||
}
|
||||
|
||||
return $query->limit(5000)->get()->all();
|
||||
$limited = LimitedQuery::get($query, 5000);
|
||||
|
||||
return [
|
||||
'rows' => $limited['rows']->all(),
|
||||
'truncated' => $limited['truncated'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -179,6 +179,11 @@ final class DrawTickService
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
\Illuminate\Support\Facades\Log::warning('draw_tick_settlement_failed', [
|
||||
'draw_id' => $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,23 @@ final class PlayerCreditService
|
||||
return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency);
|
||||
}
|
||||
|
||||
/** 逾期/代理线门禁与可用额度预检(不占额)。 */
|
||||
public function assertCreditPreflight(Player $player, int $amountMinor): void
|
||||
{
|
||||
if (! PlayerFundingMode::usesCredit($player) || $amountMinor <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertCreditGuards($player);
|
||||
|
||||
$currency = (string) $player->default_currency;
|
||||
if ($amountMinor > $this->availableCreditMinor($player, $currency)) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit' => ['insufficient'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function holdForBet(Player $player, int $amountMinor): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
@@ -73,22 +90,45 @@ final class PlayerCreditService
|
||||
}
|
||||
|
||||
$currency = (string) $player->default_currency;
|
||||
$availableMinor = $this->availableCreditMinor($player, $currency);
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||
$now = now();
|
||||
|
||||
$row = DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit' => ['insufficient'],
|
||||
]);
|
||||
}
|
||||
|
||||
$availableMajor = max(
|
||||
0,
|
||||
(int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit,
|
||||
);
|
||||
$availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currency);
|
||||
if ($amountMinor > $availableMinor) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit' => ['insufficient'],
|
||||
]);
|
||||
}
|
||||
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||
|
||||
DB::table('player_credit_accounts')
|
||||
$updated = DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->whereRaw('credit_limit - used_credit - frozen_credit >= ?', [$majorDelta])
|
||||
->update([
|
||||
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
||||
'updated_at' => now(),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($updated !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit' => ['insufficient'],
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
@@ -96,8 +136,8 @@ final class PlayerCreditService
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -113,12 +153,13 @@ final class PlayerCreditService
|
||||
|
||||
$currency = (string) $player->default_currency;
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||
$now = now();
|
||||
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->update([
|
||||
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
||||
'updated_at' => now(),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
@@ -163,6 +204,16 @@ final class PlayerCreditService
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertCreditGuards($player);
|
||||
$this->holdForBet($player, $amountMinor);
|
||||
}
|
||||
|
||||
private function assertCreditGuards(Player $player): void
|
||||
{
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overdue = DB::table('settlement_bills')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $player->id)
|
||||
@@ -181,8 +232,6 @@ final class PlayerCreditService
|
||||
AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId);
|
||||
AgentOverdueGuard::assertAgentLineMayPlaceBet($agentNodeId);
|
||||
}
|
||||
|
||||
$this->holdForBet($player, $amountMinor);
|
||||
}
|
||||
|
||||
public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void
|
||||
@@ -205,6 +254,26 @@ final class PlayerCreditService
|
||||
]);
|
||||
}
|
||||
|
||||
public function reverseBetHold(Player $player, int $amountMinor): void
|
||||
{
|
||||
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->decreaseUsedCredit($player, $amountMinor);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => $amountMinor,
|
||||
'reason' => 'bet_hold_release',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
@@ -254,12 +323,16 @@ final class PlayerCreditService
|
||||
}
|
||||
|
||||
$playerId = (int) $player->id;
|
||||
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
|
||||
|
||||
$row = DB::table('player_credit_accounts')
|
||||
->where('player_id', $playerId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($row === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
|
||||
$next = max(0, (int) $row->used_credit - $majorDelta);
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $playerId)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\WalletTxn;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\LimitedQuery;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
|
||||
use App\Services\AgentSettlement\SettlementPartyEnrichment;
|
||||
@@ -100,14 +101,15 @@ final class PlayerLedgerLogsService
|
||||
}
|
||||
|
||||
$currency = (string) $player->default_currency;
|
||||
$rawRows = $this->creditLedgerQuery($player->id, [
|
||||
$limited = LimitedQuery::get($this->creditLedgerQuery($player->id, [
|
||||
'bet_hold',
|
||||
'bet_hold_release',
|
||||
'game_settlement_loss',
|
||||
'game_settlement_win',
|
||||
'settlement_confirm',
|
||||
'settlement_payout',
|
||||
])->limit(5000)->get()->all();
|
||||
]), 5000);
|
||||
$rawRows = $limited['rows']->all();
|
||||
|
||||
$enriched = array_map(function (object $row) use ($player): object {
|
||||
return (object) [
|
||||
@@ -196,6 +198,7 @@ final class PlayerLedgerLogsService
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'truncated' => $limited['truncated'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user