feat(risk): 支持按开注商隔离风险池并新增经营报表
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

This commit is contained in:
2026-07-10 10:23:30 +08:00
parent d4779660c8
commit 2ea3e810a0
31 changed files with 610 additions and 58 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Services\Ticket;
use App\Models\BetProvider;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Lottery\ErrorCode;
@@ -22,11 +23,12 @@ final class RiskPoolService
* @param list<array{number_4d:string, amount:int}> $locks
* @return list<array{number_4d:string, amount:int, warning:bool}>
*/
public function preview(int $drawId, array $locks): array
public function preview(int $drawId, string $providerCode, array $locks): array
{
$providerCode = $this->normalizeProviderCode($providerCode);
$rows = [];
foreach ($locks as $lock) {
$pool = $this->firstOrMakePool($drawId, $lock['number_4d']);
$pool = $this->firstOrMakePool($drawId, $providerCode, $lock['number_4d']);
if ((int) $pool->sold_out_status === 1) {
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
}
@@ -53,19 +55,20 @@ final class RiskPoolService
/**
* @param list<array{number_4d:string, amount:int}> $locks
*/
public function acquire(int $drawId, ?TicketItem $ticketItem, array $locks): int
public function acquire(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): int
{
$providerCode = $this->normalizeProviderCode($providerCode);
if ($this->shouldUseRedisAtomicLocks()) {
return $this->acquireWithRedisLua($drawId, $ticketItem, $locks);
return $this->acquireWithRedisLua($drawId, $providerCode, $ticketItem, $locks);
}
return $this->acquireWithDatabaseLocks($drawId, $ticketItem, $locks);
return $this->acquireWithDatabaseLocks($drawId, $providerCode, $ticketItem, $locks);
}
/**
* @param list<array{number_4d:string, amount:int}> $locks
*/
private function acquireWithDatabaseLocks(int $drawId, ?TicketItem $ticketItem, array $locks): int
private function acquireWithDatabaseLocks(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): int
{
$acquired = [];
$total = 0;
@@ -74,14 +77,16 @@ final class RiskPoolService
foreach ($locks as $lock) {
$pool = RiskPool::query()
->where('draw_id', $drawId)
->where('provider_code', $providerCode)
->where('normalized_number', $lock['number_4d'])
->lockForUpdate()
->first();
if ($pool === null) {
$pool = $this->createPool($drawId, $lock['number_4d']);
$pool = $this->createPool($drawId, $providerCode, $lock['number_4d']);
$pool = RiskPool::query()
->where('draw_id', $drawId)
->where('provider_code', $providerCode)
->where('normalized_number', $lock['number_4d'])
->lockForUpdate()
->firstOrFail();
@@ -105,6 +110,7 @@ final class RiskPoolService
$this->riskRealtime->publishAfterLock(
$drawId,
$providerCode,
$lock['number_4d'],
$soldOutBefore,
$lockedBefore,
@@ -114,6 +120,7 @@ final class RiskPoolService
RiskPoolLockLog::query()->create([
'draw_id' => $drawId,
'provider_code' => $providerCode,
'normalized_number' => $lock['number_4d'],
'ticket_item_id' => $ticketItem?->id,
'action_type' => 'lock',
@@ -126,7 +133,7 @@ final class RiskPoolService
$total += $amount;
}
} catch (\Throwable $e) {
$this->releaseDatabaseLocks($drawId, $ticketItem, $acquired, 'ticket_failed_line');
$this->releaseDatabaseLocks($drawId, $providerCode, $ticketItem, $acquired, 'ticket_failed_line');
throw $e;
}
@@ -139,7 +146,7 @@ final class RiskPoolService
*
* @param list<array{number_4d:string, amount:int}> $locks
*/
private function acquireWithRedisLua(int $drawId, ?TicketItem $ticketItem, array $locks): int
private function acquireWithRedisLua(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): int
{
$acquired = [];
$total = 0;
@@ -148,16 +155,16 @@ final class RiskPoolService
foreach ($locks as $lock) {
$number4d = $lock['number_4d'];
$amount = (int) $lock['amount'];
$this->acquireRedisLockForCombination($drawId, $number4d, $amount);
$this->acquireRedisLockForCombination($drawId, $providerCode, $number4d, $amount);
$acquired[] = ['number_4d' => $number4d, 'amount' => $amount];
$total += $amount;
$this->syncDatabaseAfterRedisAcquire($drawId, $ticketItem, $number4d, $amount);
$this->syncDatabaseAfterRedisAcquire($drawId, $providerCode, $ticketItem, $number4d, $amount);
}
} catch (\Throwable $e) {
$this->releaseRedisLocks($drawId, $acquired);
$this->releaseDatabaseLocks($drawId, $ticketItem, $acquired, 'ticket_failed_line');
$this->releaseRedisLocks($drawId, $providerCode, $acquired);
$this->releaseDatabaseLocks($drawId, $providerCode, $ticketItem, $acquired, 'ticket_failed_line');
throw $e;
}
@@ -170,34 +177,35 @@ final class RiskPoolService
*
* @param list<array{number_4d:string, amount:int}> $locks
*/
public function compensateRedisAcquires(int $drawId, array $locks): void
public function compensateRedisAcquires(int $drawId, string $providerCode, array $locks): void
{
if ($locks === [] || ! $this->shouldUseRedisAtomicLocks()) {
return;
}
$this->releaseRedisLocks($drawId, $locks);
$this->releaseRedisLocks($drawId, $this->normalizeProviderCode($providerCode), $locks);
}
/**
* @param list<array{number_4d:string, amount:int}> $locks
*/
public function release(int $drawId, ?TicketItem $ticketItem, array $locks): void
public function release(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks): void
{
$providerCode = $this->normalizeProviderCode($providerCode);
if ($this->shouldUseRedisAtomicLocks()) {
$this->releaseRedisLocks($drawId, $locks);
$this->releaseRedisLocks($drawId, $providerCode, $locks);
}
foreach ($locks as $lock) {
$this->releaseDatabaseLocks($drawId, $ticketItem, [$lock], 'ticket_rollback');
$this->releaseDatabaseLocks($drawId, $providerCode, $ticketItem, [$lock], 'ticket_rollback');
}
}
private function acquireRedisLockForCombination(int $drawId, string $number4d, int $amount): void
private function acquireRedisLockForCombination(int $drawId, string $providerCode, string $number4d, int $amount): void
{
for ($attempt = 0; $attempt < 2; $attempt++) {
$pool = $this->firstOrMakePool($drawId, $number4d);
$key = $this->redisPoolKey($drawId, $number4d);
$pool = $this->firstOrMakePool($drawId, $providerCode, $number4d);
$key = $this->redisPoolKey($drawId, $providerCode, $number4d);
Redis::eval(
$this->initLua(),
@@ -229,6 +237,7 @@ final class RiskPoolService
if ($attempt === 0 && in_array($result['code'] ?? '', ['VERSION_CONFLICT', 'POOL_NOT_INITIALIZED'], true)) {
$freshPool = RiskPool::query()
->where('draw_id', $drawId)
->where('provider_code', $providerCode)
->where('normalized_number', $number4d)
->firstOrFail();
$this->syncRedisStateFromPool($freshPool);
@@ -253,9 +262,9 @@ final class RiskPoolService
);
}
public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void
public function publishManualSoldOut(Draw $draw, string $normalizedNumber, string $providerCode): void
{
$this->riskRealtime->publishManualSoldOut($draw, $normalizedNumber);
$this->riskRealtime->publishManualSoldOut($draw, $normalizedNumber, $this->normalizeProviderCode($providerCode));
}
/** 后台改池或释池后,将 Redis 风控快照与 DB 对齐。 */
@@ -272,7 +281,7 @@ final class RiskPoolService
Redis::eval(
$this->overwriteStateLua(),
1,
$this->redisPoolKey((int) $pool->draw_id, (string) $pool->normalized_number),
$this->redisPoolKey((int) $pool->draw_id, (string) ($pool->provider_code ?? BetProvider::DEFAULT_CODE), (string) $pool->normalized_number),
$total,
$locked,
$remaining,
@@ -290,9 +299,11 @@ final class RiskPoolService
return (bool) config('lottery.risk_pool.use_redis_lua', true);
}
private function redisPoolKey(int $drawId, string $number4d): string
private function redisPoolKey(int $drawId, string $providerCode, string $number4d): string
{
return "risk_pool:draw:{$drawId}:number:{$number4d}";
$providerCode = $this->normalizeProviderCode($providerCode);
return "risk_pool:draw:{$drawId}:provider:{$providerCode}:number:{$number4d}";
}
private function redisPoolTtlSeconds(): int
@@ -363,10 +374,11 @@ return releaseAmount
LUA;
}
private function syncDatabaseAfterRedisAcquire(int $drawId, ?TicketItem $ticketItem, string $number4d, int $amount): void
private function syncDatabaseAfterRedisAcquire(int $drawId, string $providerCode, ?TicketItem $ticketItem, string $number4d, int $amount): void
{
$pool = RiskPool::query()
->where('draw_id', $drawId)
->where('provider_code', $providerCode)
->where('normalized_number', $number4d)
->lockForUpdate()
->firstOrFail();
@@ -388,6 +400,7 @@ LUA;
$this->riskRealtime->publishAfterLock(
$drawId,
$providerCode,
$number4d,
$soldOutBefore,
$lockedBefore,
@@ -397,6 +410,7 @@ LUA;
RiskPoolLockLog::query()->create([
'draw_id' => $drawId,
'provider_code' => $providerCode,
'normalized_number' => $number4d,
'ticket_item_id' => $ticketItem?->id,
'action_type' => 'lock',
@@ -409,13 +423,13 @@ LUA;
/**
* @param list<array{number_4d:string, amount:int}> $locks
*/
private function releaseRedisLocks(int $drawId, array $locks): void
private function releaseRedisLocks(int $drawId, string $providerCode, array $locks): void
{
foreach ($locks as $lock) {
Redis::eval(
$this->releaseLua(),
1,
$this->redisPoolKey($drawId, $lock['number_4d']),
$this->redisPoolKey($drawId, $providerCode, $lock['number_4d']),
(int) $lock['amount'],
$this->redisPoolTtlSeconds(),
);
@@ -447,11 +461,12 @@ LUA;
/**
* @param list<array{number_4d:string, amount:int}> $locks
*/
private function releaseDatabaseLocks(int $drawId, ?TicketItem $ticketItem, array $locks, string $sourceReason): void
private function releaseDatabaseLocks(int $drawId, string $providerCode, ?TicketItem $ticketItem, array $locks, string $sourceReason): void
{
foreach ($locks as $lock) {
$pool = RiskPool::query()
->where('draw_id', $drawId)
->where('provider_code', $providerCode)
->where('normalized_number', $lock['number_4d'])
->lockForUpdate()
->first();
@@ -470,6 +485,7 @@ LUA;
RiskPoolLockLog::query()->create([
'draw_id' => $drawId,
'provider_code' => $providerCode,
'normalized_number' => $lock['number_4d'],
'ticket_item_id' => $ticketItem?->id,
'action_type' => 'release',
@@ -480,10 +496,11 @@ LUA;
}
}
private function firstOrMakePool(int $drawId, string $number4d): RiskPool
private function firstOrMakePool(int $drawId, string $providerCode, string $number4d): RiskPool
{
$pool = RiskPool::query()
->where('draw_id', $drawId)
->where('provider_code', $providerCode)
->where('normalized_number', $number4d)
->first();
@@ -491,15 +508,18 @@ LUA;
return $pool;
}
return $this->createPool($drawId, $number4d);
return $this->createPool($drawId, $providerCode, $number4d);
}
private function createPool(int $drawId, string $number4d): RiskPool
private function createPool(int $drawId, string $providerCode, string $number4d): RiskPool
{
$cap = $this->catalogResolver->resolveCapAmount($drawId, $number4d);
$cap = $this->catalogResolver->resolveCapAmount($drawId, $number4d, $providerCode);
return RiskPool::query()->create([
return RiskPool::query()->firstOrCreate([
'draw_id' => $drawId,
'provider_code' => $providerCode,
'normalized_number' => $number4d,
], [
'normalized_number' => $number4d,
'total_cap_amount' => $cap,
'locked_amount' => 0,
@@ -508,4 +528,11 @@ LUA;
'version' => 0,
]);
}
private function normalizeProviderCode(?string $providerCode): string
{
$normalized = strtoupper(trim((string) $providerCode));
return $normalized !== '' ? $normalized : BetProvider::DEFAULT_CODE;
}
}