feat: 添加新的错误码以支持投注功能,更新数据库填充器以增强玩法和赔率配置,扩展 API 路由以支持风险池管理
This commit is contained in:
59
app/Services/Ticket/NumberNormalizer.php
Normal file
59
app/Services/Ticket/NumberNormalizer.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Lottery\ErrorCode;
|
||||
|
||||
final class NumberNormalizer
|
||||
{
|
||||
public function normalize(string $playCode, string $number, ?string $dimension = null): string
|
||||
{
|
||||
$trimmed = strtoupper(str_replace(' ', '', trim($number)));
|
||||
|
||||
return match ($playCode) {
|
||||
'roll' => $this->normalizeRoll($trimmed),
|
||||
default => $this->normalizeDigits($playCode, $trimmed, $dimension),
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeRoll(string $value): string
|
||||
{
|
||||
if (! preg_match('/^[0-9R]{4}$/', $value)) {
|
||||
throw new TicketOperationException('invalid_roll_number', ErrorCode::BetInvalidNumber->value);
|
||||
}
|
||||
|
||||
if (! str_contains($value, 'R')) {
|
||||
throw new TicketOperationException('roll_requires_r', ErrorCode::BetInvalidPlayInput->value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function normalizeDigits(string $playCode, string $value, ?string $dimension = null): string
|
||||
{
|
||||
if (! preg_match('/^[0-9]+$/', $value)) {
|
||||
throw new TicketOperationException('invalid_number', ErrorCode::BetInvalidNumber->value);
|
||||
}
|
||||
|
||||
$length = strlen($value);
|
||||
$expected = match ($playCode) {
|
||||
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight', 'box', 'ibox', 'mbox' => 4,
|
||||
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3abc' => 3,
|
||||
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => 2,
|
||||
'head', 'tail', 'odd', 'even', 'digit_big', 'digit_small' => match ($dimension) {
|
||||
'D2' => 1,
|
||||
'D3' => 1,
|
||||
'D4', null => 1,
|
||||
default => 1,
|
||||
},
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($expected !== null && $length !== $expected) {
|
||||
throw new TicketOperationException('invalid_number_length', ErrorCode::BetInvalidNumber->value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
159
app/Services/Ticket/PlayCatalogResolver.php
Normal file
159
app/Services/Ticket/PlayCatalogResolver.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\OddsItem;
|
||||
use App\Models\OddsVersion;
|
||||
use App\Models\PlayConfigItem;
|
||||
use App\Models\PlayConfigVersion;
|
||||
use App\Models\PlayType;
|
||||
use App\Models\RiskCapItem;
|
||||
use App\Models\RiskCapVersion;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class PlayCatalogResolver
|
||||
{
|
||||
/**
|
||||
* 当前生效的三套配置版本号(无锁,供预览展示;与 {@see lockActiveConfigVersionsForPlacement} 配对使用)。
|
||||
*
|
||||
* @return array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int}
|
||||
*/
|
||||
public function currentActiveVersionStamp(): array
|
||||
{
|
||||
$playV = PlayConfigVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->orderBy('id')
|
||||
->firstOrFail();
|
||||
|
||||
$oddsV = OddsVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->orderBy('id')
|
||||
->firstOrFail();
|
||||
|
||||
$riskV = RiskCapVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->orderBy('id')
|
||||
->firstOrFail();
|
||||
|
||||
return [
|
||||
'play_config_version_no' => (int) $playV->version_no,
|
||||
'odds_version_no' => (int) $oddsV->version_no,
|
||||
'risk_cap_version_no' => (int) $riskV->version_no,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 下注事务内:按固定顺序锁住当前生效的三套配置版本,与后台切版互斥;可选与预览戳比对。
|
||||
*
|
||||
* @param array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int}|null $expectedFromPreview
|
||||
*/
|
||||
public function lockActiveConfigVersionsForPlacement(?array $expectedFromPreview = null): void
|
||||
{
|
||||
$playV = PlayConfigVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$oddsV = OddsVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$riskV = RiskCapVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($expectedFromPreview !== null) {
|
||||
if ((int) $playV->version_no !== (int) $expectedFromPreview['play_config_version_no']
|
||||
|| (int) $oddsV->version_no !== (int) $expectedFromPreview['odds_version_no']
|
||||
|| (int) $riskV->version_no !== (int) $expectedFromPreview['risk_cap_version_no']) {
|
||||
throw new TicketOperationException('config_version_stale', ErrorCode::BetConfigStale->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{play_type: PlayType, play_config: PlayConfigItem, odds_items: Collection<int, OddsItem>}
|
||||
*/
|
||||
public function resolve(string $playCode, string $currencyCode): array
|
||||
{
|
||||
$playType = PlayType::query()->where('play_code', $playCode)->first();
|
||||
if ($playType === null) {
|
||||
throw new TicketOperationException('play_not_found', ErrorCode::BetPlayUnsupported->value);
|
||||
}
|
||||
if (! $playType->is_enabled) {
|
||||
throw new TicketOperationException('play_master_disabled', ErrorCode::PlayModeClosed->value);
|
||||
}
|
||||
|
||||
$playVersion = PlayConfigVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->firstOrFail();
|
||||
|
||||
$playConfig = PlayConfigItem::query()
|
||||
->where('version_id', $playVersion->id)
|
||||
->where('play_code', $playCode)
|
||||
->first();
|
||||
|
||||
if ($playConfig === null || ! $playConfig->is_enabled) {
|
||||
throw new TicketOperationException('play_config_disabled', ErrorCode::PlayModeClosed->value);
|
||||
}
|
||||
|
||||
$oddsVersion = OddsVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->firstOrFail();
|
||||
|
||||
$oddsItems = OddsItem::query()
|
||||
->where('version_id', $oddsVersion->id)
|
||||
->where('play_code', $playCode)
|
||||
->where('currency_code', strtoupper($currencyCode))
|
||||
->get();
|
||||
|
||||
if ($oddsItems->isEmpty()) {
|
||||
throw new TicketOperationException('odds_missing', ErrorCode::BetPlayUnsupported->value);
|
||||
}
|
||||
|
||||
return [
|
||||
'play_type' => $playType,
|
||||
'play_config' => $playConfig,
|
||||
'odds_items' => $oddsItems,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveCapAmount(int $drawId, string $number4d): int
|
||||
{
|
||||
$riskVersion = RiskCapVersion::query()
|
||||
->where('status', ConfigVersionStatus::Active->value)
|
||||
->firstOrFail();
|
||||
|
||||
$specific = RiskCapItem::query()
|
||||
->where('version_id', $riskVersion->id)
|
||||
->where('draw_id', $drawId)
|
||||
->where('normalized_number', $number4d)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($specific !== null) {
|
||||
return (int) $specific->cap_amount;
|
||||
}
|
||||
|
||||
$generic = RiskCapItem::query()
|
||||
->where('version_id', $riskVersion->id)
|
||||
->whereNull('draw_id')
|
||||
->where('normalized_number', $number4d)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($generic !== null) {
|
||||
return (int) $generic->cap_amount;
|
||||
}
|
||||
|
||||
return 50_000_000_000;
|
||||
}
|
||||
}
|
||||
312
app/Services/Ticket/PlayRuleEngine.php
Normal file
312
app/Services/Ticket/PlayRuleEngine.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\OddsItem;
|
||||
use App\Models\PlayConfigItem;
|
||||
use App\Models\PlayType;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class PlayRuleEngine
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NumberNormalizer $normalizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $line
|
||||
* @param Collection<int, OddsItem> $oddsItems
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function evaluateLine(array $line, PlayType $playType, PlayConfigItem $playConfig, Collection $oddsItems): array
|
||||
{
|
||||
$playCode = (string) $line['play_code'];
|
||||
$dimension = $line['dimension'] ?? null;
|
||||
$digitSlot = $line['digit_slot'] ?? null;
|
||||
$amount = (int) $line['amount'];
|
||||
$number = $this->normalizer->normalize($playCode, (string) $line['number'], is_string($dimension) ? $dimension : null);
|
||||
|
||||
if ($amount < (int) $playConfig->min_bet_amount || $amount > (int) $playConfig->max_bet_amount) {
|
||||
throw new TicketOperationException('bet_amount_out_of_range', ErrorCode::WalletAmountExceedsLimit->value);
|
||||
}
|
||||
|
||||
if (in_array($playCode, ['odd', 'even', 'digit_big', 'digit_small'], true) && ! is_string($dimension)) {
|
||||
throw new TicketOperationException('dimension_required', ErrorCode::BetInvalidPlayInput->value);
|
||||
}
|
||||
|
||||
if (in_array($playCode, ['digit_big', 'digit_small'], true) && ! is_int($digitSlot) && ! ctype_digit((string) $digitSlot)) {
|
||||
throw new TicketOperationException('digit_slot_required', ErrorCode::BetInvalidPlayInput->value);
|
||||
}
|
||||
|
||||
$digitSlotInt = $digitSlot === null ? null : (int) $digitSlot;
|
||||
$combos = $this->expandToCombinations($playCode, $number, is_string($dimension) ? $dimension : null, $digitSlotInt);
|
||||
$combinationCount = count($combos);
|
||||
if ($combinationCount < 1) {
|
||||
throw new TicketOperationException('empty_combinations', ErrorCode::BetInvalidPlayInput->value);
|
||||
}
|
||||
|
||||
$unitBetAmount = $this->resolveUnitBetAmount($playCode, $amount, $combinationCount);
|
||||
$totalBetAmount = $this->resolveTotalBetAmount($playCode, $amount, $unitBetAmount, $combinationCount);
|
||||
$primaryOdds = $this->pickPrimaryOdds($oddsItems);
|
||||
$rebateRate = (float) $primaryOdds->rebate_rate;
|
||||
$commissionRate = (float) $primaryOdds->commission_rate;
|
||||
$actualDeductAmount = max(0, (int) floor($totalBetAmount * (1 - $rebateRate)));
|
||||
$maxOdds = $oddsItems->max(fn (OddsItem $row) => (int) $row->odds_value) ?? 0;
|
||||
$estimatedPayoutPerCombo = (int) floor($unitBetAmount * ($maxOdds / 10000));
|
||||
$estimatedMaxPayout = $estimatedPayoutPerCombo * $combinationCount;
|
||||
|
||||
return [
|
||||
'original_number' => (string) $line['number'],
|
||||
'normalized_number' => $number,
|
||||
'play_code' => $playCode,
|
||||
'dimension' => $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playType),
|
||||
'digit_slot' => $digitSlotInt,
|
||||
'bet_mode' => $playType->bet_mode,
|
||||
'unit_bet_amount' => $unitBetAmount,
|
||||
'total_bet_amount' => $totalBetAmount,
|
||||
'rebate_rate_snapshot' => number_format($rebateRate, 4, '.', ''),
|
||||
'commission_rate_snapshot' => number_format($commissionRate, 4, '.', ''),
|
||||
'actual_deduct_amount' => $actualDeductAmount,
|
||||
'combination_count' => $combinationCount,
|
||||
'estimated_max_payout' => $estimatedMaxPayout,
|
||||
'odds_snapshot_json' => $oddsItems->map(fn (OddsItem $row) => [
|
||||
'prize_scope' => $row->prize_scope,
|
||||
'odds_value' => (int) $row->odds_value,
|
||||
'rebate_rate' => (string) $row->rebate_rate,
|
||||
'commission_rate' => (string) $row->commission_rate,
|
||||
])->values()->all(),
|
||||
'rule_snapshot_json' => [
|
||||
'play_code' => $playCode,
|
||||
'dimension' => $dimension,
|
||||
'digit_slot' => $digitSlotInt,
|
||||
'combination_count' => $combinationCount,
|
||||
],
|
||||
'combinations' => collect($combos)->values()->map(function (string $combo, int $index) use ($unitBetAmount, $estimatedPayoutPerCombo): array {
|
||||
return [
|
||||
'combination_no' => $index + 1,
|
||||
'number_4d' => $combo,
|
||||
'bet_amount' => $unitBetAmount,
|
||||
'estimated_payout' => $estimatedPayoutPerCombo,
|
||||
];
|
||||
})->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandToCombinations(string $playCode, string $number, ?string $dimension, ?int $digitSlot): array
|
||||
{
|
||||
return match ($playCode) {
|
||||
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight' => [$number],
|
||||
'ibox', 'mbox', 'box' => $this->uniquePermutations($number),
|
||||
'roll' => $this->expandRoll($number),
|
||||
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3abc' => $this->expandSuffix($number, 3),
|
||||
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => $this->expandSuffix($number, 2),
|
||||
'head' => $this->expandHeadTail(true),
|
||||
'tail' => $this->expandHeadTail(false),
|
||||
'odd' => $this->expandOddEven($dimension, true),
|
||||
'even' => $this->expandOddEven($dimension, false),
|
||||
'digit_big' => $this->expandDigitSize($dimension, $digitSlot, true),
|
||||
'digit_small' => $this->expandDigitSize($dimension, $digitSlot, false),
|
||||
default => throw new TicketOperationException('unsupported_play_expand', ErrorCode::BetPlayUnsupported->value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function uniquePermutations(string $digits): array
|
||||
{
|
||||
$results = [];
|
||||
$this->permute(str_split($digits), 0, $results);
|
||||
$values = array_values(array_unique($results));
|
||||
sort($values);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $chars
|
||||
* @param array<int, string> $results
|
||||
*/
|
||||
private function permute(array $chars, int $index, array &$results): void
|
||||
{
|
||||
if ($index === count($chars) - 1) {
|
||||
$results[] = implode('', $chars);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
for ($i = $index; $i < count($chars); $i++) {
|
||||
if (isset($seen[$chars[$i]])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$chars[$i]] = true;
|
||||
[$chars[$index], $chars[$i]] = [$chars[$i], $chars[$index]];
|
||||
$this->permute($chars, $index + 1, $results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandRoll(string $pattern): array
|
||||
{
|
||||
$results = [''];
|
||||
foreach (str_split($pattern) as $char) {
|
||||
$next = [];
|
||||
if ($char === 'R') {
|
||||
foreach ($results as $prefix) {
|
||||
for ($i = 0; $i <= 9; $i++) {
|
||||
$next[] = $prefix.$i;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($results as $prefix) {
|
||||
$next[] = $prefix.$char;
|
||||
}
|
||||
}
|
||||
$results = $next;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandSuffix(string $suffix, int $length): array
|
||||
{
|
||||
$results = [];
|
||||
$prefixLength = 4 - $length;
|
||||
$max = 10 ** $prefixLength;
|
||||
for ($i = 0; $i < $max; $i++) {
|
||||
$results[] = str_pad((string) $i, $prefixLength, '0', STR_PAD_LEFT).$suffix;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandHeadTail(bool $isHead): array
|
||||
{
|
||||
$results = [];
|
||||
$start = $isHead ? 5 : 0;
|
||||
$end = $isHead ? 9 : 4;
|
||||
for ($first = $start; $first <= $end; $first++) {
|
||||
for ($rest = 0; $rest < 1000; $rest++) {
|
||||
$results[] = $first.str_pad((string) $rest, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandOddEven(?string $dimension, bool $odd): array
|
||||
{
|
||||
$digits = $odd ? ['1', '3', '5', '7', '9'] : ['0', '2', '4', '6', '8'];
|
||||
$suffixLength = match ($dimension) {
|
||||
'D2' => 2,
|
||||
'D3' => 3,
|
||||
default => 4,
|
||||
};
|
||||
$prefixLength = 4 - $suffixLength;
|
||||
$prefixMax = 10 ** max($prefixLength, 0);
|
||||
$middleLength = max($suffixLength - 1, 0);
|
||||
$middleMax = 10 ** $middleLength;
|
||||
$results = [];
|
||||
|
||||
for ($prefix = 0; $prefix < $prefixMax; $prefix++) {
|
||||
for ($mid = 0; $mid < $middleMax; $mid++) {
|
||||
foreach ($digits as $last) {
|
||||
$results[] = str_pad((string) $prefix, $prefixLength, '0', STR_PAD_LEFT)
|
||||
.str_pad((string) $mid, $middleLength, '0', STR_PAD_LEFT)
|
||||
.$last;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandDigitSize(?string $dimension, ?int $digitSlot, bool $isBig): array
|
||||
{
|
||||
if ($digitSlot === null) {
|
||||
throw new TicketOperationException('digit_slot_missing', ErrorCode::BetInvalidPlayInput->value);
|
||||
}
|
||||
|
||||
$validSlots = match ($dimension) {
|
||||
'D2' => [2, 3],
|
||||
'D3' => [1, 2, 3],
|
||||
default => [0, 1, 2, 3],
|
||||
};
|
||||
if (! in_array($digitSlot, $validSlots, true)) {
|
||||
throw new TicketOperationException('digit_slot_invalid', ErrorCode::BetInvalidPlayInput->value);
|
||||
}
|
||||
|
||||
$targetDigits = $isBig ? ['5', '6', '7', '8', '9'] : ['0', '1', '2', '3', '4'];
|
||||
$results = [];
|
||||
for ($i = 0; $i < 10000; $i++) {
|
||||
$number = str_pad((string) $i, 4, '0', STR_PAD_LEFT);
|
||||
if (in_array($number[$digitSlot], $targetDigits, true)) {
|
||||
$results[] = $number;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function resolveUnitBetAmount(string $playCode, int $amount, int $combinationCount): int
|
||||
{
|
||||
return match ($playCode) {
|
||||
'mbox' => max(0, intdiv($amount, $combinationCount)),
|
||||
default => $amount,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveTotalBetAmount(string $playCode, int $rawAmount, int $unitBetAmount, int $combinationCount): int
|
||||
{
|
||||
return match ($playCode) {
|
||||
'ibox', 'roll' => $rawAmount * $combinationCount,
|
||||
'mbox' => $unitBetAmount * $combinationCount,
|
||||
default => $rawAmount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, OddsItem> $oddsItems
|
||||
*/
|
||||
private function pickPrimaryOdds(Collection $oddsItems): OddsItem
|
||||
{
|
||||
foreach (['first', 'default', 'second', 'third', 'starter', 'consolation'] as $scope) {
|
||||
$hit = $oddsItems->firstWhere('prize_scope', $scope);
|
||||
if ($hit !== null) {
|
||||
return $hit;
|
||||
}
|
||||
}
|
||||
|
||||
return $oddsItems->firstOrFail();
|
||||
}
|
||||
|
||||
private function toDimensionInt(?string $dimension, PlayType $playType): ?int
|
||||
{
|
||||
return match ($dimension) {
|
||||
'D2' => 2,
|
||||
'D3' => 3,
|
||||
'D4' => 4,
|
||||
default => $playType->dimension === null ? null : (int) $playType->dimension,
|
||||
};
|
||||
}
|
||||
}
|
||||
159
app/Services/Ticket/RiskPoolService.php
Normal file
159
app/Services/Ticket/RiskPoolService.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\RiskPool;
|
||||
use App\Models\RiskPoolLockLog;
|
||||
use App\Models\TicketItem;
|
||||
|
||||
final class RiskPoolService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($locks as $lock) {
|
||||
$pool = $this->firstOrMakePool($drawId, $lock['number_4d']);
|
||||
$remaining = (int) $pool->remaining_amount;
|
||||
if ($remaining < (int) $lock['amount']) {
|
||||
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
|
||||
}
|
||||
|
||||
$usage = (int) $pool->total_cap_amount > 0
|
||||
? ((int) $pool->locked_amount + (int) $lock['amount']) / (int) $pool->total_cap_amount
|
||||
: 1;
|
||||
|
||||
$rows[] = [
|
||||
'number_4d' => $lock['number_4d'],
|
||||
'amount' => (int) $lock['amount'],
|
||||
'warning' => $usage >= 0.8,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{number_4d:string, amount:int}> $locks
|
||||
*/
|
||||
public function acquire(int $drawId, ?TicketItem $ticketItem, array $locks): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($locks as $lock) {
|
||||
$pool = RiskPool::query()
|
||||
->where('draw_id', $drawId)
|
||||
->where('normalized_number', $lock['number_4d'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($pool === null) {
|
||||
$pool = $this->createPool($drawId, $lock['number_4d']);
|
||||
$pool = RiskPool::query()
|
||||
->where('draw_id', $drawId)
|
||||
->where('normalized_number', $lock['number_4d'])
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
$amount = (int) $lock['amount'];
|
||||
if ((int) $pool->remaining_amount < $amount) {
|
||||
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
|
||||
}
|
||||
|
||||
$pool->forceFill([
|
||||
'locked_amount' => (int) $pool->locked_amount + $amount,
|
||||
'remaining_amount' => (int) $pool->remaining_amount - $amount,
|
||||
'sold_out_status' => ((int) $pool->remaining_amount - $amount) <= 0 ? 1 : 0,
|
||||
'version' => (int) $pool->version + 1,
|
||||
])->save();
|
||||
|
||||
RiskPoolLockLog::query()->create([
|
||||
'draw_id' => $drawId,
|
||||
'normalized_number' => $lock['number_4d'],
|
||||
'ticket_item_id' => $ticketItem?->id,
|
||||
'action_type' => 'lock',
|
||||
'amount' => $amount,
|
||||
'source_reason' => 'ticket_place',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$total += $amount;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{number_4d:string, amount:int}> $locks
|
||||
*/
|
||||
public function release(int $drawId, ?TicketItem $ticketItem, array $locks): void
|
||||
{
|
||||
foreach ($locks as $lock) {
|
||||
$pool = RiskPool::query()
|
||||
->where('draw_id', $drawId)
|
||||
->where('normalized_number', $lock['number_4d'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($pool === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = min((int) $lock['amount'], (int) $pool->locked_amount);
|
||||
$pool->forceFill([
|
||||
'locked_amount' => (int) $pool->locked_amount - $amount,
|
||||
'remaining_amount' => (int) $pool->remaining_amount + $amount,
|
||||
'sold_out_status' => 0,
|
||||
'version' => (int) $pool->version + 1,
|
||||
])->save();
|
||||
|
||||
RiskPoolLockLog::query()->create([
|
||||
'draw_id' => $drawId,
|
||||
'normalized_number' => $lock['number_4d'],
|
||||
'ticket_item_id' => $ticketItem?->id,
|
||||
'action_type' => 'release',
|
||||
'amount' => $amount,
|
||||
'source_reason' => 'ticket_rollback',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function firstOrMakePool(int $drawId, string $number4d): RiskPool
|
||||
{
|
||||
return RiskPool::query()->firstOrCreate(
|
||||
['draw_id' => $drawId, 'normalized_number' => $number4d],
|
||||
[
|
||||
'total_cap_amount' => $this->catalogResolver->resolveCapAmount($drawId, $number4d),
|
||||
'locked_amount' => 0,
|
||||
'remaining_amount' => $this->catalogResolver->resolveCapAmount($drawId, $number4d),
|
||||
'sold_out_status' => 0,
|
||||
'version' => 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function createPool(int $drawId, string $number4d): RiskPool
|
||||
{
|
||||
$cap = $this->catalogResolver->resolveCapAmount($drawId, $number4d);
|
||||
|
||||
return RiskPool::query()->create([
|
||||
'draw_id' => $drawId,
|
||||
'normalized_number' => $number4d,
|
||||
'total_cap_amount' => $cap,
|
||||
'locked_amount' => 0,
|
||||
'remaining_amount' => $cap,
|
||||
'sold_out_status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
213
app/Services/Ticket/TicketPlacementService.php
Normal file
213
app/Services/Ticket/TicketPlacementService.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketCombination;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class TicketPlacementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
private readonly PlayRuleEngine $ruleEngine,
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
private readonly TicketWalletService $ticketWalletService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function place(Player $player, array $payload): array
|
||||
{
|
||||
$currencyCode = strtoupper((string) $payload['currency_code']);
|
||||
$expectedVersions = $payload['expected_config_versions'] ?? null;
|
||||
if (is_array($expectedVersions)) {
|
||||
$expectedVersions = [
|
||||
'play_config_version_no' => (int) $expectedVersions['play_config_version_no'],
|
||||
'odds_version_no' => (int) $expectedVersions['odds_version_no'],
|
||||
'risk_cap_version_no' => (int) $expectedVersions['risk_cap_version_no'],
|
||||
];
|
||||
} else {
|
||||
$expectedVersions = null;
|
||||
}
|
||||
|
||||
$order = DB::transaction(function () use (
|
||||
$player,
|
||||
$currencyCode,
|
||||
$payload,
|
||||
$expectedVersions
|
||||
): TicketOrder {
|
||||
$draw = Draw::query()
|
||||
->where('draw_no', (string) $payload['draw_id'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($draw === null) {
|
||||
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
|
||||
}
|
||||
if ($draw->status !== DrawStatus::Open->value) {
|
||||
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
|
||||
}
|
||||
|
||||
$this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);
|
||||
|
||||
$evaluatedLines = [];
|
||||
$totalBet = 0;
|
||||
$totalRebate = 0;
|
||||
$totalActualDeduct = 0;
|
||||
$totalEstimatedPayout = 0;
|
||||
|
||||
foreach ((array) $payload['lines'] as $line) {
|
||||
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode);
|
||||
$evaluated = $this->ruleEngine->evaluateLine(
|
||||
(array) $line,
|
||||
$resolved['play_type'],
|
||||
$resolved['play_config'],
|
||||
$resolved['odds_items'],
|
||||
);
|
||||
|
||||
$locks = array_map(fn (array $combo): array => [
|
||||
'number_4d' => $combo['number_4d'],
|
||||
'amount' => $combo['estimated_payout'],
|
||||
], $evaluated['combinations']);
|
||||
$this->riskPoolService->preview((int) $draw->id, $locks);
|
||||
|
||||
$evaluatedLines[] = $evaluated;
|
||||
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
|
||||
$totalBet += (int) $evaluated['total_bet_amount'];
|
||||
$totalRebate += $rebateAmount;
|
||||
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
|
||||
$totalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
|
||||
}
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => $this->newOrderNo(),
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => $currencyCode,
|
||||
'total_bet_amount' => $totalBet,
|
||||
'total_rebate_amount' => $totalRebate,
|
||||
'total_actual_deduct' => $totalActualDeduct,
|
||||
'total_estimated_payout' => $totalEstimatedPayout,
|
||||
'status' => 'placed',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => $payload['client_trace_id'] ?? null,
|
||||
]);
|
||||
|
||||
$this->ticketWalletService->deduct($player, $currencyCode, $totalActualDeduct, $order);
|
||||
|
||||
foreach ($evaluatedLines as $evaluated) {
|
||||
$item = TicketItem::query()->create([
|
||||
'ticket_no' => $this->newTicketNo(),
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => $evaluated['original_number'],
|
||||
'normalized_number' => $this->normalizedNumberForStorage($evaluated),
|
||||
'play_code' => $evaluated['play_code'],
|
||||
'dimension' => $evaluated['dimension'],
|
||||
'digit_slot' => $evaluated['digit_slot'],
|
||||
'bet_mode' => $evaluated['bet_mode'],
|
||||
'unit_bet_amount' => $evaluated['unit_bet_amount'],
|
||||
'total_bet_amount' => $evaluated['total_bet_amount'],
|
||||
'rebate_rate_snapshot' => $evaluated['rebate_rate_snapshot'],
|
||||
'commission_rate_snapshot' => $evaluated['commission_rate_snapshot'],
|
||||
'actual_deduct_amount' => $evaluated['actual_deduct_amount'],
|
||||
'odds_snapshot_json' => $evaluated['odds_snapshot_json'],
|
||||
'rule_snapshot_json' => $evaluated['rule_snapshot_json'],
|
||||
'combination_count' => $evaluated['combination_count'],
|
||||
'estimated_max_payout' => $evaluated['estimated_max_payout'],
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'success',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => null,
|
||||
]);
|
||||
|
||||
$locks = [];
|
||||
foreach ($evaluated['combinations'] as $combo) {
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => $combo['combination_no'],
|
||||
'number_4d' => $combo['number_4d'],
|
||||
'bet_amount' => $combo['bet_amount'],
|
||||
'estimated_payout' => $combo['estimated_payout'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$locks[] = [
|
||||
'number_4d' => $combo['number_4d'],
|
||||
'amount' => $combo['estimated_payout'],
|
||||
];
|
||||
}
|
||||
|
||||
$lockedAmount = $this->riskPoolService->acquire((int) $draw->id, $item, $locks);
|
||||
$item->forceFill(['risk_locked_amount' => $lockedAmount])->save();
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
|
||||
$draw = Draw::query()->whereKey($order->draw_id)->firstOrFail();
|
||||
|
||||
return [
|
||||
'order_no' => $order->order_no,
|
||||
'draw' => [
|
||||
'draw_id' => $draw->draw_no,
|
||||
'status' => $draw->status,
|
||||
],
|
||||
'summary' => [
|
||||
'total_bet_amount' => (int) $order->total_bet_amount,
|
||||
'total_rebate_amount' => (int) $order->total_rebate_amount,
|
||||
'total_actual_deduct' => (int) $order->total_actual_deduct,
|
||||
'total_estimated_payout' => (int) $order->total_estimated_payout,
|
||||
],
|
||||
'items' => TicketItem::query()
|
||||
->where('order_id', $order->id)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (TicketItem $item) => [
|
||||
'ticket_no' => $item->ticket_no,
|
||||
'play_code' => $item->play_code,
|
||||
'number' => $item->original_number,
|
||||
'total_bet_amount' => (int) $item->total_bet_amount,
|
||||
'actual_deduct_amount' => (int) $item->actual_deduct_amount,
|
||||
'estimated_max_payout' => (int) $item->estimated_max_payout,
|
||||
'combination_count' => (int) $item->combination_count,
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function newOrderNo(): string
|
||||
{
|
||||
return 'TO'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function newTicketNo(): string
|
||||
{
|
||||
return 'TK'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* ticket_items.normalized_number 现为 char(4),短维度玩法需要统一填充。
|
||||
*
|
||||
* @param array<string, mixed> $evaluated
|
||||
*/
|
||||
private function normalizedNumberForStorage(array $evaluated): string
|
||||
{
|
||||
$number = (string) $evaluated['normalized_number'];
|
||||
if (strlen($number) === 4) {
|
||||
return $number;
|
||||
}
|
||||
|
||||
return str_pad($number, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
102
app/Services/Ticket/TicketPreviewService.php
Normal file
102
app/Services/Ticket/TicketPreviewService.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Draw;
|
||||
|
||||
final class TicketPreviewService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
private readonly PlayRuleEngine $ruleEngine,
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function preview(array $payload): array
|
||||
{
|
||||
$draw = Draw::query()->where('draw_no', (string) $payload['draw_id'])->first();
|
||||
if ($draw === null) {
|
||||
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
|
||||
}
|
||||
if ($draw->status !== DrawStatus::Open->value) {
|
||||
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
|
||||
}
|
||||
|
||||
$currencyCode = strtoupper((string) $payload['currency_code']);
|
||||
$lines = [];
|
||||
$totalBet = 0;
|
||||
$totalRebate = 0;
|
||||
$totalActualDeduct = 0;
|
||||
$totalEstimatedPayout = 0;
|
||||
$warningRows = [];
|
||||
|
||||
foreach ((array) $payload['lines'] as $index => $line) {
|
||||
$resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode);
|
||||
$evaluated = $this->ruleEngine->evaluateLine(
|
||||
(array) $line,
|
||||
$resolved['play_type'],
|
||||
$resolved['play_config'],
|
||||
$resolved['odds_items'],
|
||||
);
|
||||
|
||||
$locks = array_map(fn (array $combo): array => [
|
||||
'number_4d' => $combo['number_4d'],
|
||||
'amount' => $combo['estimated_payout'],
|
||||
], $evaluated['combinations']);
|
||||
$riskPreview = $this->riskPoolService->preview((int) $draw->id, $locks);
|
||||
foreach ($riskPreview as $riskRow) {
|
||||
if ($riskRow['warning']) {
|
||||
$warningRows[] = [
|
||||
'number_4d' => $riskRow['number_4d'],
|
||||
'message' => '该号码赔付池已使用 80% 以上,可能即将售罄',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
|
||||
$totalBet += (int) $evaluated['total_bet_amount'];
|
||||
$totalRebate += $rebateAmount;
|
||||
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
|
||||
$totalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
|
||||
|
||||
$lines[] = [
|
||||
'client_line_no' => $index + 1,
|
||||
'number' => $evaluated['original_number'],
|
||||
'play_code' => $evaluated['play_code'],
|
||||
'normalized_number' => $evaluated['normalized_number'],
|
||||
'combination_count' => $evaluated['combination_count'],
|
||||
'total_bet_amount' => $evaluated['total_bet_amount'],
|
||||
'rebate_rate' => $evaluated['rebate_rate_snapshot'],
|
||||
'rebate_amount' => $rebateAmount,
|
||||
'actual_deduct_amount' => $evaluated['actual_deduct_amount'],
|
||||
'estimated_max_payout' => $evaluated['estimated_max_payout'],
|
||||
'risk_status' => 'ok',
|
||||
'warnings' => [],
|
||||
'rule_snapshot_json' => $evaluated['rule_snapshot_json'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'draw' => [
|
||||
'draw_id' => $draw->draw_no,
|
||||
'status' => $draw->status,
|
||||
],
|
||||
'config_versions' => $this->catalogResolver->currentActiveVersionStamp(),
|
||||
'summary' => [
|
||||
'total_bet_amount' => $totalBet,
|
||||
'total_rebate_amount' => $totalRebate,
|
||||
'total_actual_deduct' => $totalActualDeduct,
|
||||
'total_estimated_payout' => $totalEstimatedPayout,
|
||||
],
|
||||
'lines' => $lines,
|
||||
'warnings' => $warningRows,
|
||||
];
|
||||
}
|
||||
}
|
||||
72
app/Services/Ticket/TicketWalletService.php
Normal file
72
app/Services/Ticket/TicketWalletService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\WalletTxn;
|
||||
|
||||
final class TicketWalletService
|
||||
{
|
||||
private const TXN_POSTED = 'posted';
|
||||
|
||||
private const TXN_DIR_OUT = 2;
|
||||
|
||||
public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): void
|
||||
{
|
||||
$wallet = PlayerWallet::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('wallet_type', 'lottery')
|
||||
->where('currency_code', strtoupper($currencyCode))
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($wallet === null) {
|
||||
$wallet = PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => strtoupper($currencyCode),
|
||||
'balance' => 0,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
$before = (int) $wallet->balance;
|
||||
if ($before < $amountMinor) {
|
||||
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
|
||||
}
|
||||
|
||||
$after = $before - $amountMinor;
|
||||
$wallet->forceFill([
|
||||
'balance' => $after,
|
||||
'version' => (int) $wallet->version + 1,
|
||||
])->save();
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => $this->newTxnNo(),
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => 'bet_deduct',
|
||||
'biz_no' => $order->order_no,
|
||||
'direction' => self::TXN_DIR_OUT,
|
||||
'amount' => $amountMinor,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'status' => self::TXN_POSTED,
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => $order->client_trace_id,
|
||||
'remark' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function newTxnNo(): string
|
||||
{
|
||||
return 'WL'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user