feat(bet-provider): 添加开注商管理功能,包括增删改查接口及相关模型和迁移文件
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

This commit is contained in:
2026-07-08 15:56:35 +08:00
parent fbe182f6e6
commit 2d50980999
19 changed files with 382 additions and 57 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\BetProvider;
use App\Models\BetProvider;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
final class AdminBetProviderController extends Controller
{
public function index(): JsonResponse
{
return ApiResponse::success([
'items' => BetProvider::query()
->orderBy('sort_order')
->orderBy('id')
->get()
->map(fn (BetProvider $provider): array => $this->present($provider))
->values()
->all(),
]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'code' => ['required', 'string', 'max:32', 'regex:/^[A-Za-z0-9_-]+$/', Rule::unique('bet_providers', 'code')],
'name' => ['required', 'string', 'max:64'],
'is_enabled' => ['sometimes', 'boolean'],
'sort_order' => ['sometimes', 'integer', 'min:0'],
]);
$provider = BetProvider::query()->create([
'code' => strtoupper((string) $data['code']),
'name' => trim((string) $data['name']),
'is_enabled' => (bool) ($data['is_enabled'] ?? true),
'sort_order' => (int) ($data['sort_order'] ?? 0),
]);
return ApiResponse::success($this->present($provider));
}
public function update(Request $request, BetProvider $betProvider): JsonResponse
{
$data = $request->validate([
'name' => ['sometimes', 'required', 'string', 'max:64'],
'is_enabled' => ['sometimes', 'boolean'],
'sort_order' => ['sometimes', 'integer', 'min:0'],
]);
$betProvider->fill(array_filter([
'name' => isset($data['name']) ? trim((string) $data['name']) : null,
'is_enabled' => array_key_exists('is_enabled', $data) ? (bool) $data['is_enabled'] : null,
'sort_order' => array_key_exists('sort_order', $data) ? (int) $data['sort_order'] : null,
], static fn (mixed $value): bool => $value !== null))->save();
$betProvider->refresh();
return ApiResponse::success($this->present($betProvider));
}
private function present(BetProvider $provider): array
{
return [
'id' => (int) $provider->id,
'code' => $provider->code,
'name' => $provider->name,
'is_enabled' => (bool) $provider->is_enabled,
'sort_order' => (int) $provider->sort_order,
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,
'created_at' => $provider->created_at?->toIso8601String(),
'updated_at' => $provider->updated_at?->toIso8601String(),
];
}
}

View File

@@ -83,6 +83,10 @@ final class AdminTicketItemIndexController extends Controller
$number = trim((string) ($validated['number'] ?? '')); $number = trim((string) ($validated['number'] ?? ''));
$this->applyTicketItemNumberSearch($query, $number); $this->applyTicketItemNumberSearch($query, $number);
$providerCode = strtoupper(trim((string) ($validated['provider_code'] ?? '')));
if ($providerCode !== '') {
$query->where('ticket_items.provider_code', $providerCode);
}
$this->applyOrderPlacedDateRange( $this->applyOrderPlacedDateRange(
$query, $query,
is_string($validated['start_date'] ?? null) ? $validated['start_date'] : null, is_string($validated['start_date'] ?? null) ? $validated['start_date'] : null,
@@ -113,6 +117,8 @@ final class AdminTicketItemIndexController extends Controller
'order_no' => $row->order?->order_no, 'order_no' => $row->order?->order_no,
'draw_no' => $row->draw?->draw_no, 'draw_no' => $row->draw?->draw_no,
'currency_code' => $row->order?->currency_code, 'currency_code' => $row->order?->currency_code,
'provider_code' => $row->provider_code,
'provider_name' => $row->provider_name,
'play_code' => $row->play_code, 'play_code' => $row->play_code,
'original_number' => $row->original_number, 'original_number' => $row->original_number,
// 历史字段:保留兼容,实际单位为 minor。 // 历史字段:保留兼容,实际单位为 minor。

View File

@@ -57,6 +57,8 @@ final class AdminTicketItemShowController extends Controller
'draw_id' => (int) $item->draw_id, 'draw_id' => (int) $item->draw_id,
'draw_no' => $item->draw?->draw_no, 'draw_no' => $item->draw?->draw_no,
'currency_code' => $item->order?->currency_code, 'currency_code' => $item->order?->currency_code,
'provider_code' => $item->provider_code,
'provider_name' => $item->provider_name,
'player_id' => $item->player_id, 'player_id' => $item->player_id,
'site_code' => $item->player?->site_code, 'site_code' => $item->player?->site_code,
'site_player_id' => $item->player?->site_player_id, 'site_player_id' => $item->player?->site_player_id,

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Api\V1\BetProvider;
use App\Models\BetProvider;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
/** GET /api/v1/bet-providers — 玩家端可选开注商。 */
final class BetProviderIndexController extends Controller
{
public function __invoke(): JsonResponse
{
$items = BetProvider::query()
->where('is_enabled', true)
->orderBy('sort_order')
->orderBy('id')
->get(['code', 'name', 'sort_order'])
->map(fn (BetProvider $provider): array => [
'code' => $provider->code,
'name' => $provider->name,
'sort_order' => (int) $provider->sort_order,
'is_default' => $provider->code === BetProvider::DEFAULT_CODE,
])
->values()
->all();
return ApiResponse::success(['items' => $items]);
}
}

View File

@@ -148,6 +148,8 @@ final class TicketItemShowController extends Controller
'order_status' => $item->order?->status, 'order_status' => $item->order?->status,
'draw_no' => $draw?->draw_no, 'draw_no' => $draw?->draw_no,
'currency_code' => $item->order?->currency_code, 'currency_code' => $item->order?->currency_code,
'provider_code' => $item->provider_code,
'provider_name' => $item->provider_name,
'play_code' => $item->play_code, 'play_code' => $item->play_code,
'dimension' => $item->dimension, 'dimension' => $item->dimension,
'digit_slot' => $item->digit_slot, 'digit_slot' => $item->digit_slot,

View File

@@ -79,6 +79,8 @@ final class TicketItemsIndexController extends Controller
'order_status' => $row->order?->status, 'order_status' => $row->order?->status,
'draw_no' => $row->draw?->draw_no, 'draw_no' => $row->draw?->draw_no,
'currency_code' => $row->order?->currency_code, 'currency_code' => $row->order?->currency_code,
'provider_code' => $row->provider_code,
'provider_name' => $row->provider_name,
'play_code' => $row->play_code, 'play_code' => $row->play_code,
'original_number' => $row->original_number, 'original_number' => $row->original_number,
'total_bet_amount' => $totalBet, 'total_bet_amount' => $totalBet,

View File

@@ -30,6 +30,7 @@ final class TicketItemListRequest extends ApiFormRequest
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'], 'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'draw_no' => ['sometimes', 'nullable', 'string', 'max:32'], 'draw_no' => ['sometimes', 'nullable', 'string', 'max:32'],
'provider_code' => ['sometimes', 'nullable', 'string', 'max:32'],
'status' => ['sometimes'], 'status' => ['sometimes'],
'status.*' => ['string', 'max:32'], 'status.*' => ['string', 'max:32'],
'number' => ['sometimes', 'nullable', 'string', 'max:64'], 'number' => ['sometimes', 'nullable', 'string', 'max:64'],

View File

@@ -20,6 +20,8 @@ abstract class TicketBetRequest extends ApiFormRequest
'draw_id' => ['required', 'string', 'max:32'], 'draw_id' => ['required', 'string', 'max:32'],
'currency_code' => ['required', 'string', 'max:16'], 'currency_code' => ['required', 'string', 'max:16'],
'client_trace_id' => ['nullable', 'string', 'max:64'], 'client_trace_id' => ['nullable', 'string', 'max:64'],
'provider_codes' => ['nullable', 'array', 'min:1', 'max:16'],
'provider_codes.*' => ['required', 'string', 'max:32', 'regex:/^[A-Za-z0-9_-]+$/'],
'lines' => ['required', 'array', 'min:1', 'max:100'], 'lines' => ['required', 'array', 'min:1', 'max:100'],
'lines.*.number' => ['required', 'string', 'max:32'], 'lines.*.number' => ['required', 'string', 'max:32'],
'lines.*.play_code' => ['required', 'string', 'max:32'], 'lines.*.play_code' => ['required', 'string', 'max:32'],

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** 开注商目录 {@see bet_providers} */
final class BetProvider extends Model
{
public const DEFAULT_CODE = 'SG';
protected $fillable = [
'code',
'name',
'is_enabled',
'sort_order',
];
protected function casts(): array
{
return [
'is_enabled' => 'boolean',
'sort_order' => 'integer',
];
}
}

View File

@@ -15,6 +15,8 @@ final class TicketItem extends Model
'order_id', 'order_id',
'player_id', 'player_id',
'draw_id', 'draw_id',
'provider_code',
'provider_name',
'original_number', 'original_number',
'normalized_number', 'normalized_number',
'play_code', 'play_code',

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Services\Ticket;
use App\Models\BetProvider;
use App\Lottery\ErrorCode;
use App\Exceptions\TicketOperationException;
final class BetProviderResolver
{
/**
* @param array<int, mixed>|null $providerCodes
* @return list<array{code: string, name: string}>
*/
public function resolve(?array $providerCodes): array
{
$usingImplicitDefault = $providerCodes === null || $providerCodes === [];
$codes = $usingImplicitDefault ? [BetProvider::DEFAULT_CODE] : $providerCodes;
$codes = array_values(array_unique(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
$codes,
))));
if ($codes === []) {
$codes = [BetProvider::DEFAULT_CODE];
$usingImplicitDefault = true;
}
$providers = BetProvider::query()
->whereIn('code', $codes)
->where('is_enabled', true)
->orderBy('sort_order')
->orderBy('id')
->get(['code', 'name'])
->keyBy('code');
$missing = array_values(array_filter(
$codes,
static fn (string $code): bool => ! $providers->has($code),
));
if (
$missing === [BetProvider::DEFAULT_CODE]
&& $usingImplicitDefault
&& ! BetProvider::query()->exists()
) {
return [[
'code' => BetProvider::DEFAULT_CODE,
'name' => 'Singapore',
]];
}
if ($missing !== []) {
throw new TicketOperationException('bet_provider_unavailable', ErrorCode::ValidationFailed->value, 422, [
'provider_codes' => $missing,
]);
}
return array_map(
static fn (string $code): array => [
'code' => $code,
'name' => (string) $providers->get($code)?->name,
],
$codes,
);
}
}

View File

@@ -30,6 +30,7 @@ final class TicketPlacementService
private readonly JackpotContributionService $jackpotContribution, private readonly JackpotContributionService $jackpotContribution,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot, private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
private readonly PlayerCreditService $playerCreditService, private readonly PlayerCreditService $playerCreditService,
private readonly BetProviderResolver $betProviderResolver,
) {} ) {}
/** /**
@@ -118,6 +119,9 @@ final class TicketPlacementService
} }
$configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions); $configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);
$providers = $this->betProviderResolver->resolve(
is_array($payload['provider_codes'] ?? null) ? $payload['provider_codes'] : null,
);
$evaluatedLines = []; $evaluatedLines = [];
$totalBet = 0; $totalBet = 0;
@@ -141,25 +145,24 @@ final class TicketPlacementService
throw $e; throw $e;
} }
$evaluated = $this->ruleEngine->evaluateLine( foreach ($providers as $provider) {
(array) $line, $evaluated = $this->ruleEngine->evaluateLine(
$resolved['play_config'], (array) $line,
$resolved['odds_items'], $resolved['play_config'],
); $resolved['odds_items'],
$evaluated = $this->rebateApplicator->apply($player, $evaluated); );
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
$evaluated['provider_code'] = $provider['code'];
$evaluated['provider_name'] = $provider['name'];
$locks = array_map(fn (array $combo): array => [ // place 阶段以 acquire 的原子扣减结果为准,允许单行售罄后形成混合成功/失败结果。
'number_4d' => $combo['number_4d'], $evaluatedLines[] = $evaluated;
'amount' => $combo['estimated_payout'], $rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
], $evaluated['combinations']); $totalBet += (int) $evaluated['total_bet_amount'];
// place 阶段以 acquire 的原子扣减结果为准,允许单行售罄后形成混合成功/失败结果。 $totalRebate += $rebateAmount;
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
$evaluatedLines[] = $evaluated; $totalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
$rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated); }
$totalBet += (int) $evaluated['total_bet_amount'];
$totalRebate += $rebateAmount;
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
$totalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
} }
if ($closedPlayCleanupRows !== []) { if ($closedPlayCleanupRows !== []) {
@@ -241,6 +244,8 @@ final class TicketPlacementService
'order_id' => $order->id, 'order_id' => $order->id,
'player_id' => $player->id, 'player_id' => $player->id,
'draw_id' => $draw->id, 'draw_id' => $draw->id,
'provider_code' => $evaluated['provider_code'],
'provider_name' => $evaluated['provider_name'],
'original_number' => $evaluated['original_number'], 'original_number' => $evaluated['original_number'],
'normalized_number' => $this->normalizedNumberForStorage($evaluated), 'normalized_number' => $this->normalizedNumberForStorage($evaluated),
'play_code' => $evaluated['play_code'], 'play_code' => $evaluated['play_code'],
@@ -483,6 +488,8 @@ final class TicketPlacementService
->get() ->get()
->map(fn (TicketItem $item) => [ ->map(fn (TicketItem $item) => [
'ticket_no' => $item->ticket_no, 'ticket_no' => $item->ticket_no,
'provider_code' => $item->provider_code,
'provider_name' => $item->provider_name,
'play_code' => $item->play_code, 'play_code' => $item->play_code,
'number' => $item->original_number, 'number' => $item->original_number,
'total_bet_amount' => (int) $item->total_bet_amount, 'total_bet_amount' => (int) $item->total_bet_amount,

View File

@@ -17,6 +17,7 @@ final class TicketPreviewService
private readonly TicketLineInstantRebateApplicator $rebateApplicator, private readonly TicketLineInstantRebateApplicator $rebateApplicator,
private readonly RiskPoolService $riskPoolService, private readonly RiskPoolService $riskPoolService,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot, private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
private readonly BetProviderResolver $betProviderResolver,
) {} ) {}
/** /**
@@ -38,6 +39,9 @@ final class TicketPreviewService
$currencyCode = strtoupper((string) $payload['currency_code']); $currencyCode = strtoupper((string) $payload['currency_code']);
$this->assertCreditCurrencyMatchesPlayer($player, $currencyCode); $this->assertCreditCurrencyMatchesPlayer($player, $currencyCode);
$providers = $this->betProviderResolver->resolve(
is_array($payload['provider_codes'] ?? null) ? $payload['provider_codes'] : null,
);
$lines = []; $lines = [];
$totalBet = 0; $totalBet = 0;
@@ -62,48 +66,52 @@ final class TicketPreviewService
throw $e; throw $e;
} }
$evaluated = $this->ruleEngine->evaluateLine( foreach ($providers as $provider) {
(array) $line, $evaluated = $this->ruleEngine->evaluateLine(
$resolved['play_config'], (array) $line,
$resolved['odds_items'], $resolved['play_config'],
); $resolved['odds_items'],
$evaluated = $this->rebateApplicator->apply($player, $evaluated); );
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
$locks = array_map(fn (array $combo): array => [ $locks = array_map(fn (array $combo): array => [
'number_4d' => $combo['number_4d'], 'number_4d' => $combo['number_4d'],
'amount' => $combo['estimated_payout'], 'amount' => $combo['estimated_payout'],
], $evaluated['combinations']); ], $evaluated['combinations']);
$riskPreview = $this->riskPoolService->preview((int) $draw->id, $locks); $riskPreview = $this->riskPoolService->preview((int) $draw->id, $locks);
foreach ($riskPreview as $riskRow) { foreach ($riskPreview as $riskRow) {
if ($riskRow['warning']) { if ($riskRow['warning']) {
$warningRows[] = [ $warningRows[] = [
'number_4d' => $riskRow['number_4d'], 'number_4d' => $riskRow['number_4d'],
'message' => '该号码赔付池已使用 80% 以上,可能即将售罄', 'message' => '该号码赔付池已使用 80% 以上,可能即将售罄',
]; ];
}
} }
$rebateAmount = $this->rebateApplicator->displayRebateAmount($player, $evaluated);
$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,
'provider_code' => $provider['code'],
'provider_name' => $provider['name'],
'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'],
];
} }
$rebateAmount = $this->rebateApplicator->displayRebateAmount($player, $evaluated);
$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'],
];
} }
if ($closedPlayCleanupRows !== []) { if ($closedPlayCleanupRows !== []) {

View File

@@ -61,6 +61,7 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.risk.manage', 'name' => '风控中心·可管理', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.manage']], ['slug' => 'prd.risk.manage', 'name' => '风控中心·可管理', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.manage']],
['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理', 'nav_segment' => 'rules_plays', 'permission_codes' => ['config.play.manage']], ['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理', 'nav_segment' => 'rules_plays', 'permission_codes' => ['config.play.manage']],
['slug' => 'prd.bet_provider.manage', 'name' => '开注商·可管理', 'nav_segment' => 'rules_plays', 'permission_codes' => ['config.play.manage']],
['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], ['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']],
['slug' => 'prd.odds.view', 'name' => '赔率配置·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.view']], ['slug' => 'prd.odds.view', 'name' => '赔率配置·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.view']],
['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.manage']], ['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.manage']],
@@ -157,6 +158,7 @@ final class AdminAuthorizationRegistry
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'agent_hidden' => true], ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'agent_hidden' => true],
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'nav_group' => 'finance', 'requiredAny' => ['prd.report.view']], ['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'nav_group' => 'finance', 'requiredAny' => ['prd.report.view']],
['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['segment' => 'bet_providers', 'label' => 'Bet providers', 'href' => '/admin/config/bet-providers', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/config/bet-providers', 'requiredAny' => ['prd.bet_provider.manage']],
['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
@@ -464,6 +466,9 @@ final class AdminAuthorizationRegistry
['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.report.view', 'prd.dashboard.view']], ['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.report.view', 'prd.dashboard.view']],
['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], ['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
['code' => 'admin.bet-providers.index', 'module_code' => 'config', 'name' => '开注商列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/bet-providers', 'route_name' => 'api.v1.admin.bet-providers.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.bet_provider.manage', 'prd.play_switch.manage']],
['code' => 'admin.bet-providers.store', 'module_code' => 'config', 'name' => '创建开注商', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/bet-providers', 'route_name' => 'api.v1.admin.bet-providers.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.bet_provider.manage']],
['code' => 'admin.bet-providers.update', 'module_code' => 'config', 'name' => '更新开注商', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/bet-providers/{betProvider}', 'route_name' => 'api.v1.admin.bet-providers.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.bet_provider.manage']],
['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], ['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], ['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['code' => 'admin.config.play-versions.store', 'module_code' => 'config', 'name' => '创建玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], ['code' => 'admin.config.play-versions.store', 'module_code' => 'config', 'name' => '创建玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bet_providers', function (Blueprint $table): void {
$table->id();
$table->string('code', 32)->unique();
$table->string('name', 64);
$table->boolean('is_enabled')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
Schema::table('ticket_items', function (Blueprint $table): void {
$table->string('provider_code', 32)->default('SG')->after('draw_id');
$table->string('provider_name', 64)->default('Singapore')->after('provider_code');
$table->index('provider_code', 'idx_ticket_items_provider_code');
});
}
public function down(): void
{
Schema::table('ticket_items', function (Blueprint $table): void {
$table->dropIndex('idx_ticket_items_provider_code');
$table->dropColumn(['provider_code', 'provider_name']);
});
Schema::dropIfExists('bet_providers');
}
};

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\BetProvider;
final class BetProviderSeeder extends Seeder
{
public function run(): void
{
$rows = [
['code' => 'SG', 'name' => 'Singapore', 'sort_order' => 10],
['code' => 'MY', 'name' => 'Malaysia', 'sort_order' => 20],
['code' => 'TH', 'name' => 'Thailand', 'sort_order' => 30],
];
foreach ($rows as $row) {
BetProvider::query()->updateOrCreate(
['code' => $row['code']],
[
'name' => $row['name'],
'is_enabled' => true,
'sort_order' => $row['sort_order'],
],
);
}
}
}

View File

@@ -20,6 +20,7 @@ final class FoundationSeeder extends Seeder
{ {
$this->call([ $this->call([
CurrencySeeder::class, CurrencySeeder::class,
BetProviderSeeder::class,
PlayTypeSeeder::class, PlayTypeSeeder::class,
OperationalConfigV1Seeder::class, OperationalConfigV1Seeder::class,
OddsPrizeScopesBackfillSeeder::class, OddsPrizeScopesBackfillSeeder::class,

View File

@@ -22,6 +22,7 @@ use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionPublishController;
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionDestroyController; use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionDestroyController;
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionDestroyController; use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionDestroyController;
use App\Http\Controllers\Api\V1\Admin\AdminSettingController; use App\Http\Controllers\Api\V1\Admin\AdminSettingController;
use App\Http\Controllers\Api\V1\Admin\BetProvider\AdminBetProviderController;
/** /**
* 管理员配置管理路由(玩法/赔率/封顶)。 * 管理员配置管理路由(玩法/赔率/封顶)。
@@ -32,6 +33,19 @@ Route::middleware('admin.api-resource')
->get('play-types', PlayTypeIndexController::class) ->get('play-types', PlayTypeIndexController::class)
->name('api.v1.admin.play-types.index'); ->name('api.v1.admin.play-types.index');
Route::middleware('admin.api-resource')
->prefix('bet-providers')
->name('api.v1.admin.bet-providers.')
->group(function (): void {
Route::get('/', [AdminBetProviderController::class, 'index'])
->name('index');
Route::post('/', [AdminBetProviderController::class, 'store'])
->name('store');
Route::put('{betProvider}', [AdminBetProviderController::class, 'update'])
->whereNumber('betProvider')
->name('update');
});
// 玩法版本只读 // 玩法版本只读
Route::middleware('admin.api-resource') Route::middleware('admin.api-resource')
->prefix('config') ->prefix('config')

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\Api\V1\Ticket\TicketItemsIndexController;
use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController;
use App\Http\Controllers\Api\V1\Ticket\TicketDrawMyMatchController; use App\Http\Controllers\Api\V1\Ticket\TicketDrawMyMatchController;
use App\Http\Controllers\Api\V1\Wallet\WalletTransferOutController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferOutController;
use App\Http\Controllers\Api\V1\BetProvider\BetProviderIndexController;
/** /**
* 玩家端路由(需 middleware lottery.player * 玩家端路由(需 middleware lottery.player
@@ -49,4 +50,7 @@ Route::middleware('lottery.player')->group(function (): void {
->where('draw_no', '[0-9]{8}-[0-9]{3}') ->where('draw_no', '[0-9]{8}-[0-9]{3}')
->name('draws.my-match'); ->name('draws.my-match');
}); });
Route::get('bet-providers', BetProviderIndexController::class)
->name('api.v1.bet-providers.index');
}); });