feat: 添加新的错误码以支持投注功能,更新数据库填充器以增强玩法和赔率配置,扩展 API 路由以支持风险池管理

This commit is contained in:
2026-05-11 11:52:23 +08:00
parent 067c2b39f5
commit 058f596f34
29 changed files with 2300 additions and 122 deletions

View 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;
}
}

View 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;
}
}

View 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,
};
}
}

View 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,
]);
}
}

View 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);
}
}

View 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,
];
}
}

View 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);
}
}