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,20 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* 下注业务失败异常,由 Ticket 控制器捕获并转为统一 JSON。
*/
final class TicketOperationException extends RuntimeException
{
public function __construct(
string $message,
public readonly int $lotteryCode,
public readonly int $httpStatus = 400,
public readonly mixed $payload = null,
) {
parent::__construct($message);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Risk;
use App\Http\Controllers\Controller;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Support\ApiResponse;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* GET /api/v1/admin/draws/{draw}/risk-pools 按期号分页查询赔付池事实(售罄筛选、排序)。
*
* 对齐产品文档:后台查看售罄与风险占用、热门号码监控(按占用排序)。
*/
final class AdminRiskPoolIndexController extends Controller
{
public function __invoke(Request $request, Draw $draw): JsonResponse
{
$perPage = min(max((int) $request->integer('per_page', 25), 1), 100);
$soldOutOnly = $request->boolean('sold_out_only');
$sort = trim((string) $request->query('sort', 'usage_desc'));
$q = RiskPool::query()->where('draw_id', $draw->id);
if ($soldOutOnly) {
$q->where('sold_out_status', 1);
}
match ($sort) {
'locked_desc' => $q->orderByDesc('locked_amount')->orderBy('normalized_number'),
'remaining_asc' => $q->orderBy('remaining_amount')->orderBy('normalized_number'),
'number_asc' => $q->orderBy('normalized_number'),
default => $q->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC')
->orderByDesc('locked_amount')
->orderBy('normalized_number'),
};
/** @var LengthAwarePaginator $paginator */
$paginator = $q->paginate($perPage);
return ApiResponse::success([
'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'items' => collect($paginator->items())->map(fn (RiskPool $row) => $this->row($row))->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
]);
}
/** @return array<string, mixed> */
private function row(RiskPool $pool): array
{
$cap = (int) $pool->total_cap_amount;
$locked = (int) $pool->locked_amount;
return [
'normalized_number' => $pool->normalized_number,
'total_cap_amount' => $cap,
'locked_amount' => $locked,
'remaining_amount' => (int) $pool->remaining_amount,
'sold_out_status' => (int) $pool->sold_out_status,
'is_sold_out' => (int) $pool->sold_out_status === 1,
'usage_ratio' => $cap > 0 ? round($locked / $cap, 6) : null,
'version' => (int) $pool->version,
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Risk;
use App\Http\Controllers\Controller;
use App\Models\Draw;
use App\Models\RiskPoolLockLog;
use App\Support\ApiResponse;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* GET /api/v1/admin/draws/{draw}/risk-pool-lock-logs 风险池占用/释放流水(审计与监控)。
*/
final class AdminRiskPoolLockLogIndexController extends Controller
{
public function __invoke(Request $request, Draw $draw): JsonResponse
{
$perPage = min(max((int) $request->integer('per_page', 25), 1), 100);
$action = trim((string) $request->query('action_type', ''));
$number = trim((string) $request->query('normalized_number', ''));
$q = RiskPoolLockLog::query()
->where('draw_id', $draw->id)
->with(['ticketItem:id,ticket_no,play_code,player_id'])
->orderByDesc('created_at')
->orderByDesc('id');
if ($action !== '' && in_array($action, ['lock', 'release'], true)) {
$q->where('action_type', $action);
}
if ($number !== '' && preg_match('/^[0-9]{4}$/', $number) === 1) {
$q->where('normalized_number', $number);
}
/** @var LengthAwarePaginator $paginator */
$paginator = $q->paginate($perPage);
return ApiResponse::success([
'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'items' => collect($paginator->items())->map(fn (RiskPoolLockLog $log) => $this->row($log))->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
]);
}
/** @return array<string, mixed> */
private function row(RiskPoolLockLog $log): array
{
return [
'id' => (int) $log->id,
'normalized_number' => $log->normalized_number,
'action_type' => $log->action_type,
'amount' => (int) $log->amount,
'source_reason' => $log->source_reason,
'ticket_item_id' => $log->ticket_item_id,
'ticket_no' => $log->ticketItem?->ticket_no,
'play_code' => $log->ticketItem?->play_code,
'player_id' => $log->ticketItem?->player_id,
'created_at' => $log->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Risk;
use App\Http\Controllers\Controller;
use App\Lottery\ErrorCode;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Models\RiskPoolLockLog;
use App\Support\ApiResponse;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* GET /api/v1/admin/draws/{draw}/risk-pools/{number_4d} 单号码风险池详情 + 占用流水。
*/
final class AdminRiskPoolShowController extends Controller
{
public function __invoke(Request $request, Draw $draw, string $number_4d): JsonResponse
{
if (preg_match('/^[0-9]{4}$/', $number_4d) !== 1) {
return ApiResponse::error('normalized_number 须为 4 位数字', ErrorCode::ValidationFailed->value, null, 422);
}
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', $number_4d)
->first();
if ($pool === null) {
return ApiResponse::error('该期尚无此号码的风险池记录', ErrorCode::NotFound->value, null, 404);
}
$perPage = min(max((int) $request->integer('per_page', 20), 1), 100);
/** @var LengthAwarePaginator $paginator */
$paginator = RiskPoolLockLog::query()
->where('draw_id', $draw->id)
->where('normalized_number', $number_4d)
->with(['ticketItem:id,ticket_no,play_code,player_id'])
->orderByDesc('created_at')
->orderByDesc('id')
->paginate($perPage);
$cap = (int) $pool->total_cap_amount;
$locked = (int) $pool->locked_amount;
return ApiResponse::success([
'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'pool' => [
'normalized_number' => $pool->normalized_number,
'total_cap_amount' => $cap,
'locked_amount' => $locked,
'remaining_amount' => (int) $pool->remaining_amount,
'sold_out_status' => (int) $pool->sold_out_status,
'is_sold_out' => (int) $pool->sold_out_status === 1,
'usage_ratio' => $cap > 0 ? round($locked / $cap, 6) : null,
'version' => (int) $pool->version,
],
'logs' => [
'items' => collect($paginator->items())->map(fn (RiskPoolLockLog $log) => $this->logRow($log))->all(),
'meta' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
],
]);
}
/** @return array<string, mixed> */
private function logRow(RiskPoolLockLog $log): array
{
return [
'id' => (int) $log->id,
'action_type' => $log->action_type,
'amount' => (int) $log->amount,
'source_reason' => $log->source_reason,
'ticket_item_id' => $log->ticket_item_id,
'ticket_no' => $log->ticketItem?->ticket_no,
'play_code' => $log->ticketItem?->play_code,
'player_id' => $log->ticketItem?->player_id,
'created_at' => $log->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api\V1\Ticket;
use App\Exceptions\TicketOperationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Ticket\TicketPlaceRequest;
use App\Services\Ticket\TicketPlacementService;
use App\Support\ApiResponse;
use App\Support\LotteryMessage;
use Illuminate\Http\JsonResponse;
final class TicketPlaceController extends Controller
{
public function __construct(
private readonly TicketPlacementService $placementService,
) {}
public function __invoke(TicketPlaceRequest $request): JsonResponse
{
$player = $request->lotteryPlayer();
abort_if($player === null, 500, 'lottery_player missing');
try {
$data = $this->placementService->place($player, $request->validated());
} catch (TicketOperationException $e) {
return ApiResponse::error(
LotteryMessage::wallet($request, $e->lotteryCode),
$e->lotteryCode,
$e->payload,
$e->httpStatus,
);
}
return ApiResponse::success($data);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api\V1\Ticket;
use App\Exceptions\TicketOperationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Ticket\TicketPreviewRequest;
use App\Services\Ticket\TicketPreviewService;
use App\Support\ApiResponse;
use App\Support\LotteryMessage;
use Illuminate\Http\JsonResponse;
final class TicketPreviewController extends Controller
{
public function __construct(
private readonly TicketPreviewService $previewService,
) {}
public function __invoke(TicketPreviewRequest $request): JsonResponse
{
try {
$data = $this->previewService->preview($request->validated());
} catch (TicketOperationException $e) {
return ApiResponse::error(
LotteryMessage::wallet($request, $e->lotteryCode),
$e->lotteryCode,
$e->payload,
$e->httpStatus,
);
}
return ApiResponse::success($data);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Ticket;
class TicketPlaceRequest extends TicketPreviewRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return array_merge(parent::rules(), [
'expected_config_versions' => ['nullable', 'array'],
'expected_config_versions.play_config_version_no' => ['required_with:expected_config_versions', 'integer', 'min:1'],
'expected_config_versions.odds_version_no' => ['required_with:expected_config_versions', 'integer', 'min:1'],
'expected_config_versions.risk_cap_version_no' => ['required_with:expected_config_versions', 'integer', 'min:1'],
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Ticket;
use Illuminate\Foundation\Http\FormRequest;
class TicketPreviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'draw_id' => ['required', 'string', 'max:32'],
'currency_code' => ['required', 'string', 'max:16'],
'client_trace_id' => ['nullable', 'string', 'max:64'],
'lines' => ['required', 'array', 'min:1', 'max:100'],
'lines.*.number' => ['required', 'string', 'max:32'],
'lines.*.play_code' => ['required', 'string', 'max:32'],
'lines.*.amount' => ['required', 'integer', 'min:1'],
'lines.*.digit_slot' => ['nullable', 'integer', 'min:0', 'max:3'],
'lines.*.dimension' => ['nullable', 'string', 'in:D2,D3,D4'],
];
}
}

View File

@@ -60,6 +60,26 @@ enum ErrorCode: int
/** PRD下注语境余额不足可与 1001 同语义) */
case BetInsufficientBalance = 2003;
/** 下注号码格式或长度不合法 */
case BetInvalidNumber = 2004;
/** 下注玩法不支持或参数缺失 */
case BetInvalidPlayInput = 2005;
/** 当前期号不存在或不是可下注期号 */
case BetInvalidDraw = 2006;
/** 当前玩法不存在或暂不支持下注 */
case BetPlayUnsupported = 2007;
/**
* 预览后玩法/赔率/封顶等配置版本已切换,需重新预览再提交。
*/
case BetConfigStale = 2008;
/** 风险池额度不足,号码已售罄 */
case RiskPoolSoldOut = 4001;
/** 配置版本不是草稿,无法整表替换 items 或发布 */
case ConfigVersionNotDraft = 2101;

37
app/Models/RiskPool.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** 赔付池事实表 {@see risk_pools} */
class RiskPool extends Model
{
protected $fillable = [
'draw_id',
'normalized_number',
'total_cap_amount',
'locked_amount',
'remaining_amount',
'sold_out_status',
'version',
];
protected function casts(): array
{
return [
'draw_id' => 'integer',
'total_cap_amount' => 'integer',
'locked_amount' => 'integer',
'remaining_amount' => 'integer',
'sold_out_status' => 'integer',
'version' => 'integer',
];
}
public function draw(): BelongsTo
{
return $this->belongsTo(Draw::class);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** 风险池占用日志 {@see risk_pool_lock_logs} */
class RiskPoolLockLog extends Model
{
public $timestamps = false;
protected $fillable = [
'draw_id',
'normalized_number',
'ticket_item_id',
'action_type',
'amount',
'source_reason',
'created_at',
];
protected function casts(): array
{
return [
'draw_id' => 'integer',
'ticket_item_id' => 'integer',
'amount' => 'integer',
'created_at' => 'datetime',
];
}
public function draw(): BelongsTo
{
return $this->belongsTo(Draw::class);
}
public function ticketItem(): BelongsTo
{
return $this->belongsTo(TicketItem::class, 'ticket_item_id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** 注项展开组合 {@see ticket_combinations} */
class TicketCombination extends Model
{
public $timestamps = false;
protected $fillable = [
'ticket_item_id',
'combination_no',
'number_4d',
'bet_amount',
'estimated_payout',
'created_at',
];
protected function casts(): array
{
return [
'ticket_item_id' => 'integer',
'combination_no' => 'integer',
'bet_amount' => 'integer',
'estimated_payout' => 'integer',
'created_at' => 'datetime',
];
}
public function item(): BelongsTo
{
return $this->belongsTo(TicketItem::class, 'ticket_item_id');
}
}

84
app/Models/TicketItem.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** 注项明细 {@see ticket_items} */
class TicketItem extends Model
{
protected $fillable = [
'ticket_no',
'order_id',
'player_id',
'draw_id',
'original_number',
'normalized_number',
'play_code',
'dimension',
'digit_slot',
'bet_mode',
'unit_bet_amount',
'total_bet_amount',
'rebate_rate_snapshot',
'commission_rate_snapshot',
'actual_deduct_amount',
'odds_snapshot_json',
'rule_snapshot_json',
'combination_count',
'estimated_max_payout',
'risk_locked_amount',
'status',
'fail_reason_code',
'fail_reason_text',
'win_amount',
'jackpot_win_amount',
'settled_at',
];
protected function casts(): array
{
return [
'order_id' => 'integer',
'player_id' => 'integer',
'draw_id' => 'integer',
'dimension' => 'integer',
'digit_slot' => 'integer',
'unit_bet_amount' => 'integer',
'total_bet_amount' => 'integer',
'rebate_rate_snapshot' => 'decimal:4',
'commission_rate_snapshot' => 'decimal:4',
'actual_deduct_amount' => 'integer',
'odds_snapshot_json' => 'json',
'rule_snapshot_json' => 'json',
'combination_count' => 'integer',
'estimated_max_payout' => 'integer',
'risk_locked_amount' => 'integer',
'win_amount' => 'integer',
'jackpot_win_amount' => 'integer',
'settled_at' => 'datetime',
];
}
public function order(): BelongsTo
{
return $this->belongsTo(TicketOrder::class, 'order_id');
}
public function player(): BelongsTo
{
return $this->belongsTo(Player::class);
}
public function draw(): BelongsTo
{
return $this->belongsTo(Draw::class);
}
public function combinations(): HasMany
{
return $this->hasMany(TicketCombination::class, 'ticket_item_id');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** 下注订单头 {@see ticket_orders} */
class TicketOrder extends Model
{
protected $fillable = [
'order_no',
'player_id',
'draw_id',
'currency_code',
'total_bet_amount',
'total_rebate_amount',
'total_actual_deduct',
'total_estimated_payout',
'status',
'submit_source',
'client_trace_id',
];
protected function casts(): array
{
return [
'player_id' => 'integer',
'draw_id' => 'integer',
'total_bet_amount' => 'integer',
'total_rebate_amount' => 'integer',
'total_actual_deduct' => 'integer',
'total_estimated_payout' => 'integer',
];
}
public function player(): BelongsTo
{
return $this->belongsTo(Player::class);
}
public function draw(): BelongsTo
{
return $this->belongsTo(Draw::class);
}
public function items(): HasMany
{
return $this->hasMany(TicketItem::class, 'order_id');
}
}

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

View File

@@ -16,80 +16,117 @@ use Illuminate\Support\Facades\DB;
/**
* 阶段 4:写入首套 **active** 玩法配置 / 赔率 / 风控封顶版本(依赖 {@see PlayTypeSeeder}{@see CurrencySeeder})。
*
* 幂等:仅当三套版本均已有 active 行时跳过;否则只补缺失的一类(避免「仅有 play active 时整段被跳过」导致 /play/effective 不可用)。
*/
class OperationalConfigV1Seeder extends Seeder
{
public function run(): void
{
if (PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->exists()) {
$hasPlay = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->exists();
$hasOdds = OddsVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->exists();
$hasRisk = RiskCapVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->exists();
if ($hasPlay && $hasOdds && $hasRisk) {
return;
}
DB::transaction(function (): void {
$playVersion = PlayConfigVersion::query()->create([
'version_no' => 1,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'seed:v1',
]);
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
PlayConfigItem::query()->create([
'version_id' => $playVersion->id,
'play_code' => $pt->play_code,
'is_enabled' => (bool) $pt->is_enabled,
'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000,
'display_order' => (int) $pt->sort_order,
'rule_text_zh' => null,
'rule_text_en' => null,
'rule_text_ne' => null,
'extra_config_json' => null,
]);
DB::transaction(function () use ($hasPlay, $hasOdds, $hasRisk): void {
if (! $hasPlay) {
$this->seedActivePlayConfigVersion();
}
$oddsVersion = OddsVersion::query()->create([
'version_no' => 1,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'seed:v1',
]);
/** 对齐界面文档 §5.5:头/二/三/特别/安慰odds_value = 乘数×10000NPR 基准展示口径) */
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
OddsItem::query()->create([
'version_id' => $oddsVersion->id,
'play_code' => $pt->play_code,
'prize_scope' => $scope,
'odds_value' => $oddsValue,
'rebate_rate' => 0,
'commission_rate' => 0,
'currency_code' => 'NPR',
'extra_config_json' => null,
]);
}
if (! $hasOdds) {
$this->seedActiveOddsVersion();
}
$riskVersion = RiskCapVersion::query()->create([
'version_no' => 1,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'seed:v1',
]);
foreach (['0000', '1234', '9999'] as $num) {
RiskCapItem::query()->create([
'version_id' => $riskVersion->id,
'draw_id' => null,
'normalized_number' => $num,
'cap_amount' => 50_000_000_000,
'cap_type' => 'per_number',
]);
if (! $hasRisk) {
$this->seedActiveRiskCapVersion();
}
});
}
private function seedActivePlayConfigVersion(): void
{
$versionNo = (int) (PlayConfigVersion::query()->max('version_no') ?? 0) + 1;
$playVersion = PlayConfigVersion::query()->create([
'version_no' => $versionNo,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'seed:v1',
]);
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
PlayConfigItem::query()->create([
'version_id' => $playVersion->id,
'play_code' => $pt->play_code,
'is_enabled' => (bool) $pt->is_enabled,
'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000,
'display_order' => (int) $pt->sort_order,
'rule_text_zh' => null,
'rule_text_en' => null,
'rule_text_ne' => null,
'extra_config_json' => null,
]);
}
}
private function seedActiveOddsVersion(): void
{
$versionNo = (int) (OddsVersion::query()->max('version_no') ?? 0) + 1;
$oddsVersion = OddsVersion::query()->create([
'version_no' => $versionNo,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'seed:v1',
]);
/** 对齐界面文档 §5.5:头/二/三/特别/安慰odds_value = 乘数×10000NPR 基准展示口径) */
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
OddsItem::query()->create([
'version_id' => $oddsVersion->id,
'play_code' => $pt->play_code,
'prize_scope' => $scope,
'odds_value' => $oddsValue,
'rebate_rate' => 0,
'commission_rate' => 0,
'currency_code' => 'NPR',
'extra_config_json' => null,
]);
}
}
}
private function seedActiveRiskCapVersion(): void
{
$versionNo = (int) (RiskCapVersion::query()->max('version_no') ?? 0) + 1;
$riskVersion = RiskCapVersion::query()->create([
'version_no' => $versionNo,
'status' => ConfigVersionStatus::Active->value,
'effective_at' => now(),
'updated_by' => null,
'reason' => 'seed:v1',
]);
foreach (['0000', '1234', '9999'] as $num) {
RiskCapItem::query()->create([
'version_id' => $riskVersion->id,
'draw_id' => null,
'normalized_number' => $num,
'cap_amount' => 50_000_000_000,
'cap_type' => 'per_number',
]);
}
}
}

View File

@@ -5,75 +5,87 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 首期玩法占位数据play_code `docs/04` 玩法编码(小写)一致,便于前端/配置对齐。
*/
class PlayTypeSeeder extends Seeder
{
public function run(): void
{
$defaults = ['created_at' => now(), 'updated_at' => now()];
$rows = [
[
'play_code' => 'big',
'category' => 'standard',
'dimension' => 2,
'bet_mode' => null,
'display_name_zh' => 'Big',
'display_name_en' => 'Big',
'display_name_ne' => 'Big',
'is_enabled' => true,
'sort_order' => 10,
'supports_multi_number' => false,
'reserved_rule_json' => null,
],
[
'play_code' => 'small',
'category' => 'standard',
'dimension' => 2,
'bet_mode' => null,
'display_name_zh' => 'Small',
'display_name_en' => 'Small',
'display_name_ne' => 'Small',
'is_enabled' => true,
'sort_order' => 20,
'supports_multi_number' => false,
'reserved_rule_json' => null,
],
[
'play_code' => 'head',
'category' => 'digit',
'dimension' => 2,
'bet_mode' => null,
'display_name_zh' => 'Head',
'display_name_en' => 'Head',
'display_name_ne' => 'Head',
'is_enabled' => true,
'sort_order' => 30,
'supports_multi_number' => false,
'reserved_rule_json' => null,
],
[
'play_code' => 'tail',
'category' => 'digit',
'dimension' => 2,
'bet_mode' => null,
'display_name_zh' => 'Tail',
'display_name_en' => 'Tail',
'display_name_ne' => 'Tail',
'is_enabled' => true,
'sort_order' => 40,
'supports_multi_number' => false,
'reserved_rule_json' => null,
],
];
foreach ($rows as $row) {
foreach ($this->rows() as $row) {
DB::table('play_types')->updateOrInsert(
['play_code' => $row['play_code']],
array_merge($row, $defaults),
);
}
}
/**
* @return list<array<string, mixed>>
*/
private function rows(): array
{
return [
$this->row('big', 'standard', 4, 'single', 'Big', 10),
$this->row('small', 'standard', 4, 'single', 'Small', 20),
$this->row('pos_4a', 'position', 4, 'single', '4A', 30, false, ['prize_scope' => ['first']]),
$this->row('pos_4b', 'position', 4, 'single', '4B', 40, false, ['prize_scope' => ['second']]),
$this->row('pos_4c', 'position', 4, 'single', '4C', 50, false, ['prize_scope' => ['third']]),
$this->row('pos_4d', 'position', 4, 'single', '4D', 60, false, ['prize_scope' => ['starter']]),
$this->row('pos_4e', 'position', 4, 'single', '4E', 70, false, ['prize_scope' => ['consolation']]),
$this->row('pos_3a', 'position', 3, 'single', '3A', 80, false, ['prize_scope' => ['first']]),
$this->row('pos_3b', 'position', 3, 'single', '3B', 90, false, ['prize_scope' => ['second']]),
$this->row('pos_3c', 'position', 3, 'single', '3C', 100, false, ['prize_scope' => ['third']]),
$this->row('pos_3abc', 'position', 3, 'single', '3ABC', 110, false, ['prize_scope' => ['first', 'second', 'third']]),
$this->row('pos_2a', 'position', 2, 'single', '2A', 120, false, ['prize_scope' => ['first']]),
$this->row('pos_2b', 'position', 2, 'single', '2B', 130, false, ['prize_scope' => ['second']]),
$this->row('pos_2c', 'position', 2, 'single', '2C', 140, false, ['prize_scope' => ['third']]),
$this->row('pos_2abc', 'position', 2, 'single', '2ABC', 150, false, ['prize_scope' => ['first', 'second', 'third']]),
$this->row('straight', 'box', 4, 'single', 'Straight', 160, false, ['expand_mode' => 'straight']),
$this->row('box', 'box', 4, 'single', 'Box', 170, false, ['expand_mode' => 'box']),
$this->row('ibox', 'box', 4, 'per_combination', 'iBox', 180, true, ['expand_mode' => 'box']),
$this->row('mbox', 'box', 4, 'shared_total', 'mBox', 190, true, ['expand_mode' => 'box']),
$this->row('roll', 'box', 4, 'per_combination', 'Roll', 200, true, ['expand_mode' => 'roll']),
$this->row('half_box', 'box', 4, 'single', 'Half Box', 210, true, ['reserved' => true], false),
$this->row('head', 'attribute', 4, 'single', 'Head', 220, false, ['attribute' => 'head']),
$this->row('tail', 'attribute', 4, 'single', 'Tail', 230, false, ['attribute' => 'tail']),
$this->row('odd', 'attribute', null, 'single', 'Odd', 240, false, ['attribute' => 'odd']),
$this->row('even', 'attribute', null, 'single', 'Even', 250, false, ['attribute' => 'even']),
$this->row('digit_big', 'attribute', null, 'single', 'Big Digit', 260, false, ['attribute' => 'digit_big']),
$this->row('digit_small', 'attribute', null, 'single', 'Small Digit', 270, false, ['attribute' => 'digit_small']),
];
}
/**
* @return array<string, mixed>
*/
private function row(
string $playCode,
string $category,
?int $dimension,
?string $betMode,
string $name,
int $sortOrder,
bool $supportsMultiNumber = false,
?array $rules = null,
bool $isEnabled = true,
): array {
return [
'play_code' => $playCode,
'category' => $category,
'dimension' => $dimension,
'bet_mode' => $betMode,
'display_name_zh' => $name,
'display_name_en' => $name,
'display_name_ne' => $name,
'is_enabled' => $isEnabled,
'sort_order' => $sortOrder,
'supports_multi_number' => $supportsMultiNumber,
'reserved_rule_json' => $rules === null ? null : json_encode($rules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
];
}
}

View File

@@ -14,4 +14,13 @@ return [
'1008' => 'Invalid amount; enter a positive integer in minor units',
'1009' => 'Main wallet operation failed; please try again later',
'1010' => 'Do not reuse an idempotency key with different transfer parameters',
'2001' => 'The current draw is already closed',
'2002' => 'This play is closed',
'2003' => 'Insufficient balance. Please transfer in before betting',
'2004' => 'Invalid number format',
'2005' => 'Play input is incomplete or invalid',
'2006' => 'The draw is not open for betting',
'2007' => 'This play is not supported yet',
'2008' => 'Odds or play settings changed; please preview again before placing',
'4001' => 'This number is sold out for the current draw',
];

View File

@@ -13,4 +13,13 @@ return [
'1008' => 'रकम अमान्य',
'1009' => 'मुख्य वालेट असफल, पछि प्रयास गर्नुहोस्',
'1010' => 'एउटै कुञ्जी दोहोर्याउनुहुन्न',
'2001' => 'हालको ड्र बन्द भइसकेको छ',
'2002' => 'यो खेल बन्द गरिएको छ',
'2003' => 'ब्यालेन्स अपर्याप्त छ, कृपया पहिले ट्रान्सफर इन गर्नुहोस्',
'2004' => 'नम्बरको ढाँचा अमान्य छ',
'2005' => 'खेल इनपुट अपूर्ण वा अमान्य छ',
'2006' => 'यो ड्र अहिले बेटिङका लागि खुला छैन',
'2007' => 'यो खेल अझै समर्थित छैन',
'2008' => 'अड्स वा सेटिङ परिवर्तन भयो; पुन: पूर्वावलोकन गर्नुहोस्',
'4001' => 'यो नम्बर हालको ड्रका लागि sold out भइसकेको छ',
];

View File

@@ -13,4 +13,13 @@ return [
'1008' => '金额无效,请输入正整数(最小货币单位)',
'1009' => '主站钱包处理失败,请稍后重试',
'1010' => '请勿重复使用幂等键发起不同金额的转账',
'2001' => '当前期已封盘,暂不可下注',
'2002' => '玩法已关闭',
'2003' => '余额不足,请先转入后再下注',
'2004' => '号码格式不正确',
'2005' => '玩法参数不完整或不合法',
'2006' => '当前期号不可下注',
'2007' => '该玩法暂不支持下注',
'2008' => '赔率或玩法配置已变更,请重新预览后再提交',
'4001' => '该号码本期已售罄',
];

View File

@@ -25,6 +25,9 @@ use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController;
use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController;
use App\Http\Controllers\Api\V1\Admin\PlayTypeIndexController;
use App\Http\Controllers\Api\V1\Admin\PlayTypePatchController;
use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolIndexController;
use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolLockLogIndexController;
use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolShowController;
use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController;
use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController;
use App\Http\Controllers\Api\V1\Draw\DrawCurrentController;
@@ -34,6 +37,8 @@ use App\Http\Controllers\Api\V1\HealthController;
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
use App\Http\Controllers\Api\V1\Player\MeController;
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController;
use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController;
use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController;
use App\Http\Controllers\Api\V1\Wallet\WalletLogsController;
use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController;
@@ -86,6 +91,13 @@ Route::prefix('v1')->group(function (): void {
// 名称:彩票 → 主站 转出
Route::post('transfer-out', WalletTransferOutController::class)->name('transfer-out');
});
Route::prefix('ticket')
->name('api.v1.ticket.')
->group(function (): void {
Route::post('preview', TicketPreviewController::class)->name('preview');
Route::post('place', TicketPlaceController::class)->name('place');
});
});
Route::prefix('admin')
@@ -114,6 +126,14 @@ Route::prefix('v1')->group(function (): void {
Route::get('draws/{draw}', AdminDrawShowController::class)->name('draws.show');
Route::get('draws/{draw}/result-batches', AdminDrawResultBatchesIndexController::class)
->name('draws.result-batches.index');
// 阶段 5风险池 / 占用流水 / 售罄监控(后台 §13.4
Route::get('draws/{draw}/risk-pools/{number_4d}', AdminRiskPoolShowController::class)
->where('number_4d', '[0-9]{4}')
->name('draws.risk-pools.show');
Route::get('draws/{draw}/risk-pool-lock-logs', AdminRiskPoolLockLogIndexController::class)
->name('draws.risk-pool-lock-logs.index');
Route::get('draws/{draw}/risk-pools', AdminRiskPoolIndexController::class)
->name('draws.risk-pools.index');
// 名称:发布待审核开奖批次(人工审核)
Route::post(
'draws/{draw}/result-batches/{batch}/publish',

View File

@@ -0,0 +1,133 @@
<?php
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Models\RiskPoolLockLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
function mintRiskAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'risk_pool_admin',
'name' => 'Risk QA',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin risk pools index returns rows for draw', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-001',
'business_date' => '2026-05-12',
'sequence_no' => 1,
'status' => 'open',
'start_time' => now()->subHour(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 1_000_000,
'locked_amount' => 200_000,
'remaining_amount' => 800_000,
'sold_out_status' => 0,
'version' => 1,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '9999',
'total_cap_amount' => 100,
'locked_amount' => 100,
'remaining_amount' => 0,
'sold_out_status' => 1,
'version' => 2,
]);
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?per_page=10')
->assertOk()
->assertJsonPath('data.draw_no', '20260512-001')
->assertJsonPath('data.meta.total', 2);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?sold_out_only=1')
->assertOk()
->assertJsonPath('data.meta.total', 1)
->assertJsonPath('data.items.0.normalized_number', '9999')
->assertJsonPath('data.items.0.is_sold_out', true);
});
test('admin risk pool lock logs include ticket_no when linked', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-002',
'business_date' => '2026-05-12',
'sequence_no' => 2,
'status' => 'open',
'start_time' => now()->subHour(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
RiskPoolLockLog::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '5678',
'ticket_item_id' => null,
'action_type' => 'lock',
'amount' => 50,
'source_reason' => 'ticket_place',
'created_at' => now(),
]);
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs')
->assertOk()
->assertJsonPath('data.meta.total', 1)
->assertJsonPath('data.items.0.amount', 50);
});
test('admin risk pool show 404 when pool missing', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-003',
'business_date' => '2026-05-12',
'sequence_no' => 3,
'status' => 'open',
'start_time' => now()->subHour(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/0000')
->assertStatus(404);
});

View File

@@ -0,0 +1,227 @@
<?php
use App\Lottery\ConfigVersionStatus;
use App\Lottery\DrawStatus;
use App\Lottery\ErrorCode;
use App\Models\Draw;
use App\Models\OddsVersion;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\RiskPool;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\WalletTxn;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Database\Seeders\PlayTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function ticketPlayerWithWallet(int $balance = 200_000): Player
{
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ticket-player-1',
'username' => 'tp1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => $balance,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}
function ticketOpenDraw(string $drawNo = '20260511-001'): Draw
{
return Draw::query()->create([
'draw_no' => $drawNo,
'business_date' => '2026-05-11',
'sequence_no' => 1,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(2),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
}
function ticketPreviewPayload(string $drawNo = '20260511-001'): array
{
return [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'trace-001',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
[
'number' => '1234',
'play_code' => 'ibox',
'amount' => 100,
],
],
];
}
test('ticket preview returns computed summary for open draw', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400)
->assertJsonPath('data.config_versions.play_config_version_no', 1)
->assertJsonPath('data.config_versions.odds_version_no', 1)
->assertJsonPath('data.config_versions.risk_cap_version_no', 1)
->assertJsonCount(2, 'data.lines');
});
test('ticket place deducts wallet and persists order items combinations and logs', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400);
expect(TicketOrder::query()->count())->toBe(1);
expect(TicketItem::query()->count())->toBe(2);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
expect(RiskPool::query()->count())->toBeGreaterThan(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});
test('ticket place rejects closed draw', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$draw->update(['status' => DrawStatus::Closed->value]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
test('ticket place rejects insufficient balance', function (): void {
$player = ticketPlayerWithWallet(1_000);
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);
expect(TicketOrder::query()->count())->toBe(0);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket place succeeds when expected_config_versions matches preview', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versions = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $versions]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
expect(TicketOrder::query()->count())->toBe(1);
});
test('ticket place rejects stale expected_config_versions', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$preview = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->update(['version_no' => 99]);
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $preview]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetConfigStale->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects sold out risk pool', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 100,
'locked_amount' => 100,
'remaining_amount' => 0,
'sold_out_status' => 1,
'version' => 1,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-002',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000);
expect(TicketOrder::query()->count())->toBe(0);
});