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