diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionDestroyController.php new file mode 100644 index 0000000..a1b652d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsVersionDestroyController.php @@ -0,0 +1,39 @@ +lotteryAdmin(); + + /** @var OddsVersion $version */ + $version = OddsVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status === ConfigVersionStatus::Active->value) { + return ApiResponse::error( + 'cannot delete active config version', + ErrorCode::ConfigVersionCannotDeleteActive->value, + null, + 400, + ); + } + + $service->deleteVersion($version, $admin, $request); + + return ApiResponse::success(['deleted' => true]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionDestroyController.php new file mode 100644 index 0000000..54b71e7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigVersionDestroyController.php @@ -0,0 +1,39 @@ +lotteryAdmin(); + + /** @var PlayConfigVersion $version */ + $version = PlayConfigVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status === ConfigVersionStatus::Active->value) { + return ApiResponse::error( + 'cannot delete active config version', + ErrorCode::ConfigVersionCannotDeleteActive->value, + null, + 400, + ); + } + + $service->deleteVersion($version, $admin, $request); + + return ApiResponse::success(['deleted' => true]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionDestroyController.php new file mode 100644 index 0000000..c5fbf97 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Config/RiskCapVersionDestroyController.php @@ -0,0 +1,39 @@ +lotteryAdmin(); + + /** @var RiskCapVersion $version */ + $version = RiskCapVersion::query()->whereKey($id)->firstOrFail(); + + if ($version->status === ConfigVersionStatus::Active->value) { + return ApiResponse::error( + 'cannot delete active config version', + ErrorCode::ConfigVersionCannotDeleteActive->value, + null, + 400, + ); + } + + $service->deleteVersion($version, $admin, $request); + + return ApiResponse::success(['deleted' => true]); + } +} diff --git a/app/Http/Requests/Ticket/TicketBetRequest.php b/app/Http/Requests/Ticket/TicketBetRequest.php new file mode 100644 index 0000000..03c10cf --- /dev/null +++ b/app/Http/Requests/Ticket/TicketBetRequest.php @@ -0,0 +1,31 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Ticket/TicketPlaceRequest.php b/app/Http/Requests/Ticket/TicketPlaceRequest.php index e4d9f82..2d69697 100644 --- a/app/Http/Requests/Ticket/TicketPlaceRequest.php +++ b/app/Http/Requests/Ticket/TicketPlaceRequest.php @@ -2,7 +2,7 @@ namespace App\Http\Requests\Ticket; -final class TicketPlaceRequest extends TicketPreviewRequest +final class TicketPlaceRequest extends TicketBetRequest { /** * @return array diff --git a/app/Http/Requests/Ticket/TicketPreviewRequest.php b/app/Http/Requests/Ticket/TicketPreviewRequest.php index c6cd45a..64e335b 100644 --- a/app/Http/Requests/Ticket/TicketPreviewRequest.php +++ b/app/Http/Requests/Ticket/TicketPreviewRequest.php @@ -2,30 +2,6 @@ namespace App\Http\Requests\Ticket; -use Illuminate\Foundation\Http\FormRequest; - -final class TicketPreviewRequest extends FormRequest +final class TicketPreviewRequest extends TicketBetRequest { - public function authorize(): bool - { - return true; - } - - /** - * @return array - */ - 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'], - ]; - } } diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index ead8468..8563f8c 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -89,6 +89,9 @@ enum ErrorCode: int /** 赔率 / 目录币种未启用或不可下注 */ case ConfigCurrencyInvalid = 2103; + /** 不能删除当前生效(active)的配置版本 */ + case ConfigVersionCannotDeleteActive = 2104; + /* ========== 8000–8999 玩家 SSO / Bearer 鉴权 ========== */ /** 无 Bearer / 格式错误 / token 为空 */ diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index e93a95a..1eb95ba 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -149,6 +149,26 @@ final class OddsStreamService ); } + public function deleteVersion(OddsVersion $version, AdminUser $admin, ?Request $request = null): void + { + $before = $this->snapshotVersion($version); + + DB::transaction(function () use ($version): void { + $version->delete(); + }); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'odds', + actionCode: 'delete', + targetType: 'odds_version', + targetId: (string) $version->id, + beforeJson: $before, + afterJson: null, + ); + } + /** @return array */ private function snapshotVersion(OddsVersion $v): array { diff --git a/app/Services/Config/PlayConfigStreamService.php b/app/Services/Config/PlayConfigStreamService.php index 4ae12bd..01eea5e 100644 --- a/app/Services/Config/PlayConfigStreamService.php +++ b/app/Services/Config/PlayConfigStreamService.php @@ -147,6 +147,26 @@ final class PlayConfigStreamService ); } + public function deleteVersion(PlayConfigVersion $version, AdminUser $admin, ?Request $request = null): void + { + $before = $this->snapshotVersion($version); + + DB::transaction(function () use ($version): void { + $version->delete(); + }); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'play_config', + actionCode: 'delete', + targetType: 'play_config_version', + targetId: (string) $version->id, + beforeJson: $before, + afterJson: null, + ); + } + /** @return array */ private function snapshotVersion(PlayConfigVersion $v): array { diff --git a/app/Services/Config/RiskCapStreamService.php b/app/Services/Config/RiskCapStreamService.php index a90bf00..31948e6 100644 --- a/app/Services/Config/RiskCapStreamService.php +++ b/app/Services/Config/RiskCapStreamService.php @@ -131,6 +131,26 @@ final class RiskCapStreamService ); } + public function deleteVersion(RiskCapVersion $version, AdminUser $admin, ?Request $request = null): void + { + $before = $this->snapshotVersion($version); + + DB::transaction(function () use ($version): void { + $version->delete(); + }); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'risk_cap', + actionCode: 'delete', + targetType: 'risk_cap_version', + targetId: (string) $version->id, + beforeJson: $before, + afterJson: null, + ); + } + /** @return array */ private function snapshotVersion(RiskCapVersion $v): array { diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index 1a3af8b..0a6bcbe 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -4,6 +4,7 @@ namespace App\Services\Draw; use Carbon\Carbon; use App\Models\Draw; +use App\Models\RiskPool; use App\Lottery\DrawStatus; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; @@ -144,6 +145,33 @@ final class DrawHallSnapshotBuilder 'seconds_remaining_in_cooldown' => $coolingRemain, ]; + $riskAlerts = RiskPool::query() + ->where('draw_id', $target->id) + ->where(function ($q): void { + $q->where('sold_out_status', 1) + ->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8'); + }) + ->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC') + ->orderByDesc('locked_amount') + ->orderBy('normalized_number') + ->limit(500) + ->get(['normalized_number', 'total_cap_amount', 'locked_amount', 'remaining_amount', 'sold_out_status']) + ->map(fn ($row) => [ + 'normalized_number' => (string) $row->normalized_number, + 'total_cap_amount' => (int) $row->total_cap_amount, + 'locked_amount' => (int) $row->locked_amount, + 'remaining_amount' => (int) $row->remaining_amount, + 'sold_out_status' => (int) $row->sold_out_status, + 'is_sold_out' => (int) $row->sold_out_status === 1, + 'usage_ratio' => (int) $row->total_cap_amount > 0 + ? round(((int) $row->locked_amount) / (int) $row->total_cap_amount, 6) + : null, + ]) + ->values() + ->all(); + + $payload['risk_pool_alerts'] = $riskAlerts; + if ($this->showsPublishedResults((string) $target->status)) { $batchId = DrawResultBatch::query() ->where('draw_id', $target->id) diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index ae5fc9b..77c5127 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -41,12 +41,12 @@ final class TicketPlacementService $expectedVersions = null; } - $order = DB::transaction(function () use ( + $placement = DB::transaction(function () use ( $player, $currencyCode, $payload, $expectedVersions - ): TicketOrder { + ): array { $draw = Draw::query() ->where('draw_no', (string) $payload['draw_id']) ->lockForUpdate() @@ -103,7 +103,7 @@ final class TicketPlacementService 'client_trace_id' => $payload['client_trace_id'] ?? null, ]); - $this->ticketWalletService->deduct($player, $currencyCode, $totalActualDeduct, $order); + $balanceAfter = $this->ticketWalletService->deduct($player, $currencyCode, $totalActualDeduct, $order); foreach ($evaluatedLines as $evaluated) { $item = TicketItem::query()->create([ @@ -157,9 +157,15 @@ final class TicketPlacementService $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, $currencyCode); } - return $order; + return [ + 'order' => $order, + 'balance_after' => $balanceAfter, + ]; }); + $order = $placement['order']; + $balanceAfter = $placement['balance_after']; + $draw = Draw::query()->whereKey($order->draw_id)->firstOrFail(); return [ @@ -174,6 +180,7 @@ final class TicketPlacementService 'total_actual_deduct' => (int) $order->total_actual_deduct, 'total_estimated_payout' => (int) $order->total_estimated_payout, ], + 'balance_after' => $balanceAfter, 'items' => TicketItem::query() ->where('order_id', $order->id) ->orderBy('id') diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index 7197845..9c35eb0 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -17,7 +17,7 @@ final class TicketWalletService private const TXN_DIR_IN = 1; - public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): void + public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): int { $wallet = PlayerWallet::query() ->where('player_id', $player->id) @@ -65,6 +65,8 @@ final class TicketWalletService 'idempotent_key' => $order->client_trace_id, 'remark' => null, ]); + + return $after; } /** diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php index 31ef55c..86d0204 100644 --- a/app/Services/Wallet/LotteryTransferService.php +++ b/app/Services/Wallet/LotteryTransferService.php @@ -137,28 +137,17 @@ final class LotteryTransferService DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void { $wallet = $this->lockLotteryWallet($player, $currencyCode); - $before = (int) $wallet->balance; - $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' => self::BIZ_TRANSFER_IN, - 'biz_no' => $transferNo, - 'direction' => self::TXN_DIR_IN, - 'amount' => $amountMinor, - 'balance_before' => $before, - 'balance_after' => $after, - 'status' => self::TXN_POSTED, - 'external_ref_no' => $main->externalRefNo, - 'idempotent_key' => $idempotentKey, - 'remark' => null, - ]); + $this->postLotteryWalletMovement( + wallet: $wallet, + bizType: self::BIZ_TRANSFER_IN, + direction: self::TXN_DIR_IN, + amountMinor: $amountMinor, + bizNo: $transferNo, + externalRefNo: $main->externalRefNo, + idempotentKey: $idempotentKey, + remark: null, + deltaSign: 1, + ); $order->forceFill([ 'status' => self::ST_SUCCESS, @@ -222,34 +211,18 @@ final class LotteryTransferService try { DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey): void { $wallet = $this->lockLotteryWallet($player, $currencyCode); - $before = (int) $wallet->balance; - if ($before < $amountMinor) { - throw new WalletOperationException( - 'insufficient_balance', - ErrorCode::WalletInsufficientBalance->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' => self::BIZ_TRANSFER_OUT, - 'biz_no' => $transferNo, - 'direction' => self::TXN_DIR_OUT, - 'amount' => $amountMinor, - 'balance_before' => $before, - 'balance_after' => $after, - 'status' => self::TXN_POSTED, - 'external_ref_no' => null, - 'idempotent_key' => $idempotentKey, - 'remark' => null, - ]); + $this->postLotteryWalletMovement( + wallet: $wallet, + bizType: self::BIZ_TRANSFER_OUT, + direction: self::TXN_DIR_OUT, + amountMinor: $amountMinor, + bizNo: $transferNo, + externalRefNo: null, + idempotentKey: $idempotentKey, + remark: null, + deltaSign: -1, + requireBalance: true, + ); }); } catch (WalletOperationException $e) { if ($e->lotteryCode === ErrorCode::WalletInsufficientBalance->value) { @@ -292,28 +265,17 @@ final class LotteryTransferService DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey, $order, $main): void { $wallet = $this->lockLotteryWallet($player, $currencyCode); - $before = (int) $wallet->balance; - $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' => self::BIZ_TRANSFER_OUT_REFUND, - 'biz_no' => $transferNo, - 'direction' => self::TXN_DIR_IN, - 'amount' => $amountMinor, - 'balance_before' => $before, - 'balance_after' => $after, - 'status' => self::TXN_POSTED, - 'external_ref_no' => null, - 'idempotent_key' => $idempotentKey, - 'remark' => 'withdraw_failed_refund', - ]); + $this->postLotteryWalletMovement( + wallet: $wallet, + bizType: self::BIZ_TRANSFER_OUT_REFUND, + direction: self::TXN_DIR_IN, + amountMinor: $amountMinor, + bizNo: $transferNo, + externalRefNo: null, + idempotentKey: $idempotentKey, + remark: 'withdraw_failed_refund', + deltaSign: 1, + ); $order->forceFill([ 'status' => self::ST_FAILED, @@ -386,28 +348,17 @@ final class LotteryTransferService if ($order->direction === self::DIR_OUT) { DB::transaction(function () use ($order, $remark): void { $wallet = $this->lockLotteryWalletById($order->player_id, $order->currency_code); - $before = (int) $wallet->balance; - $after = $before + (int) $order->amount; - $wallet->forceFill([ - 'balance' => $after, - 'version' => (int) $wallet->version + 1, - ])->save(); - - WalletTxn::query()->create([ - 'txn_no' => $this->newTxnNo(), - 'player_id' => (int) $order->player_id, - 'wallet_id' => $wallet->id, - 'biz_type' => self::BIZ_REVERSAL, - 'biz_no' => $order->transfer_no, - 'direction' => self::TXN_DIR_IN, - 'amount' => (int) $order->amount, - 'balance_before' => $before, - 'balance_after' => $after, - 'status' => self::TXN_POSTED, - 'external_ref_no' => null, - 'idempotent_key' => null, - 'remark' => $remark ?: 'reversal_pending_reconcile', - ]); + $this->postLotteryWalletMovement( + wallet: $wallet, + bizType: self::BIZ_REVERSAL, + direction: self::TXN_DIR_IN, + amountMinor: (int) $order->amount, + bizNo: $order->transfer_no, + externalRefNo: null, + idempotentKey: null, + remark: $remark ?: 'reversal_pending_reconcile', + deltaSign: 1, + ); $order->forceFill([ 'status' => self::ST_REVERSED, @@ -701,6 +652,61 @@ final class LotteryTransferService return 'WX_'.Str::lower(str_replace('-', '', Str::uuid()->toString())); } + /** + * 统一执行彩票钱包余额变更并记录流水。 + * + * @return array{before: int, after: int} + */ + private function postLotteryWalletMovement( + PlayerWallet $wallet, + string $bizType, + int $direction, + int $amountMinor, + string $bizNo, + ?string $externalRefNo, + ?string $idempotentKey, + ?string $remark, + int $deltaSign, + bool $requireBalance = false, + ): array { + $before = (int) $wallet->balance; + if ($requireBalance && $deltaSign < 0 && $before < $amountMinor) { + throw new WalletOperationException( + 'insufficient_balance', + ErrorCode::WalletInsufficientBalance->value, + ); + } + + $delta = $amountMinor * $deltaSign; + $after = $before + $delta; + + $wallet->forceFill([ + 'balance' => $after, + 'version' => (int) $wallet->version + 1, + ])->save(); + + WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => $wallet->player_id, + 'wallet_id' => $wallet->id, + 'biz_type' => $bizType, + 'biz_no' => $bizNo, + 'direction' => $direction, + 'amount' => $amountMinor, + 'balance_before' => $before, + 'balance_after' => $after, + 'status' => self::TXN_POSTED, + 'external_ref_no' => $externalRefNo, + 'idempotent_key' => $idempotentKey, + 'remark' => $remark, + ]); + + return [ + 'before' => $before, + 'after' => $after, + ]; + } + private function failedOrderToException(TransferOrder $order): WalletOperationException { if (($order->fail_reason ?? '') === 'insufficient_balance') { diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e6f7e16..f551d99 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,8 @@ final class DatabaseSeeder extends Seeder PlayTypeSeeder::class, OperationalConfigV1Seeder::class, OddsPrizeScopesBackfillSeeder::class, + /** 对齐玩法目录与 active/draft 配置行(修复历史子集种子) */ + PlayOperationalAlignmentSeeder::class, LotterySettingsSeeder::class, ]); diff --git a/database/seeders/PlayOperationalAlignmentSeeder.php b/database/seeders/PlayOperationalAlignmentSeeder.php new file mode 100644 index 0000000..b0f8b47 --- /dev/null +++ b/database/seeders/PlayOperationalAlignmentSeeder.php @@ -0,0 +1,140 @@ +call(PlayTypeSeeder::class); + + DB::transaction(function (): void { + $open = [ConfigVersionStatus::Active->value, ConfigVersionStatus::Draft->value]; + + foreach (PlayConfigVersion::query()->whereIn('status', $open)->orderBy('id')->cursor() as $version) { + $this->syncPlayConfigItems($version); + } + + foreach (OddsVersion::query()->whereIn('status', $open)->orderBy('id')->cursor() as $version) { + $this->syncOddsItemsForAllPlayTypes($version); + } + }); + } + + private function syncPlayConfigItems(PlayConfigVersion $version): void + { + $vid = (int) $version->id; + $existing = PlayConfigItem::query() + ->where('version_id', $vid) + ->pluck('play_code') + ->all(); + $have = array_fill_keys($existing, true); + + foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->cursor() as $pt) { + if (isset($have[$pt->play_code])) { + continue; + } + + PlayConfigItem::query()->create([ + 'version_id' => $vid, + 'play_code' => $pt->play_code, + 'is_enabled' => (bool) $pt->is_enabled, + 'min_bet_amount' => self::MIN_BET, + 'max_bet_amount' => self::MAX_BET, + 'display_order' => (int) $pt->sort_order, + 'rule_text_zh' => null, + 'rule_text_en' => null, + 'rule_text_ne' => null, + 'extra_config_json' => null, + ]); + } + } + + private function syncOddsItemsForAllPlayTypes(OddsVersion $version): void + { + $vid = (int) $version->id; + + $currencies = OddsItem::query() + ->where('version_id', $vid) + ->distinct() + ->pluck('currency_code') + ->filter(static fn ($c) => is_string($c) && $c !== '') + ->values(); + + if ($currencies->isEmpty()) { + $fallback = Currency::query() + ->where('is_bettable', true) + ->where('is_enabled', true) + ->orderBy('code') + ->value('code'); + if ($fallback === null || $fallback === '') { + return; + } + $currencies = collect([strtoupper((string) $fallback)]); + } + + foreach ($currencies as $currencyCode) { + $currencyCode = strtoupper((string) $currencyCode); + + foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->cursor() as $pt) { + $playCode = $pt->play_code; + + $anchor = OddsItem::query() + ->where('version_id', $vid) + ->where('play_code', $playCode) + ->where('currency_code', $currencyCode) + ->orderByDesc('id') + ->first(); + + $rebate = (float) ($anchor?->rebate_rate ?? 0); + $commission = (float) ($anchor?->commission_rate ?? 0); + + foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) { + $exists = OddsItem::query() + ->where('version_id', $vid) + ->where('play_code', $playCode) + ->where('currency_code', $currencyCode) + ->where('prize_scope', $scope) + ->exists(); + if ($exists) { + continue; + } + + OddsItem::query()->create([ + 'version_id' => $vid, + 'play_code' => $playCode, + 'prize_scope' => $scope, + 'odds_value' => $oddsValue, + 'rebate_rate' => $rebate, + 'commission_rate' => $commission, + 'currency_code' => $currencyCode, + 'extra_config_json' => null, + ]); + } + } + } + } +} diff --git a/routes/api/v1/admin/config.php b/routes/api/v1/admin/config.php index 3197a84..bf0dd4c 100644 --- a/routes/api/v1/admin/config.php +++ b/routes/api/v1/admin/config.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\Admin\Config\OddsItemsReplaceController; use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionIndexController; use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionStoreController; use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionPublishController; +use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionDestroyController; use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionShowController; use App\Http\Controllers\Api\V1\Admin\Config\RiskCapItemsReplaceController; use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionIndexController; @@ -18,6 +19,8 @@ use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigItemsReplaceController; use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionIndexController; use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionStoreController; 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\RiskCapVersionDestroyController; use App\Http\Controllers\Api\V1\Admin\AdminSettingController; /** @@ -84,6 +87,9 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r Route::post('play-versions/{id}/publish', PlayConfigVersionPublishController::class) ->whereNumber('id') ->name('play-versions.publish'); + Route::delete('play-versions/{id}', PlayConfigVersionDestroyController::class) + ->whereNumber('id') + ->name('play-versions.destroy'); // 赔率版本写入 Route::post('odds-versions', OddsVersionStoreController::class) @@ -94,6 +100,9 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r Route::post('odds-versions/{id}/publish', OddsVersionPublishController::class) ->whereNumber('id') ->name('odds-versions.publish'); + Route::delete('odds-versions/{id}', OddsVersionDestroyController::class) + ->whereNumber('id') + ->name('odds-versions.destroy'); // 封顶版本写入 Route::post('risk-cap-versions', RiskCapVersionStoreController::class) @@ -104,6 +113,9 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r Route::post('risk-cap-versions/{id}/publish', RiskCapVersionPublishController::class) ->whereNumber('id') ->name('risk-cap-versions.publish'); + Route::delete('risk-cap-versions/{id}', RiskCapVersionDestroyController::class) + ->whereNumber('id') + ->name('risk-cap-versions.destroy'); }); }); diff --git a/tests/Feature/OperationalConfigApiTest.php b/tests/Feature/OperationalConfigApiTest.php index 7fb9ccf..a847ee8 100644 --- a/tests/Feature/OperationalConfigApiTest.php +++ b/tests/Feature/OperationalConfigApiTest.php @@ -2,8 +2,11 @@ use App\Models\PlayType; use App\Models\AdminUser; +use App\Models\OddsVersion; +use App\Models\RiskCapVersion; use App\Models\PlayConfigVersion; use App\Lottery\ConfigVersionStatus; +use App\Lottery\ErrorCode; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; use Illuminate\Support\Facades\Hash; @@ -86,3 +89,65 @@ test('admin play config draft publish flow', function (): void { test('admin play-types requires authentication', function (): void { $this->getJson('/api/v1/admin/play-types')->assertUnauthorized(); }); + +test('admin cannot delete active play config version', function (): void { + $token = mintConfigAdminToken(); + $active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail(); + + $this->deleteJson('/api/v1/admin/config/play-versions/'.$active->id, [], [ + 'Authorization' => 'Bearer '.$token, + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::ConfigVersionCannotDeleteActive->value); +}); + +test('admin can delete draft play config version', function (): void { + $token = mintConfigAdminToken(); + $create = $this->postJson('/api/v1/admin/config/play-versions', [ + 'reason' => 'to delete', + ], ['Authorization' => 'Bearer '.$token]); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $this->deleteJson('/api/v1/admin/config/play-versions/'.$draftId, [], [ + 'Authorization' => 'Bearer '.$token, + ]) + ->assertOk() + ->assertJsonPath('data.deleted', true); + + expect(PlayConfigVersion::query()->whereKey($draftId)->exists())->toBeFalse(); +}); + +test('admin can delete draft odds version', function (): void { + $token = mintConfigAdminToken(); + $create = $this->postJson('/api/v1/admin/config/odds-versions', [ + 'reason' => 'to delete', + ], ['Authorization' => 'Bearer '.$token]); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $this->deleteJson('/api/v1/admin/config/odds-versions/'.$draftId, [], [ + 'Authorization' => 'Bearer '.$token, + ]) + ->assertOk() + ->assertJsonPath('data.deleted', true); + + expect(OddsVersion::query()->whereKey($draftId)->exists())->toBeFalse(); +}); + +test('admin can delete draft risk cap version', function (): void { + $token = mintConfigAdminToken(); + $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', [ + 'reason' => 'to delete', + ], ['Authorization' => 'Bearer '.$token]); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $this->deleteJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, [], [ + 'Authorization' => 'Bearer '.$token, + ]) + ->assertOk() + ->assertJsonPath('data.deleted', true); + + expect(RiskCapVersion::query()->whereKey($draftId)->exists())->toBeFalse(); +});