diff --git a/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php b/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php index 5e9684a..4f15a46 100644 --- a/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php +++ b/app/Http/Controllers/Api/V1/Admin/Config/OddsItemsReplaceController.php @@ -39,8 +39,8 @@ final class OddsItemsReplaceController extends Controller 'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')], 'items.*.prize_scope' => ['required', 'string', 'max:32'], 'items.*.odds_value' => ['required', 'integer', 'min:0'], - 'items.*.rebate_rate' => ['sometimes', 'numeric'], - 'items.*.commission_rate' => ['sometimes', 'numeric'], + 'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'], + 'items.*.commission_rate' => ['sometimes', 'numeric', 'between:0,1'], 'items.*.currency_code' => ['required', 'string', 'max:16', Rule::exists('currencies', 'code')], 'items.*.extra_config_json' => ['sometimes', 'nullable', 'array'], ]); diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index 1eb95ba..fffa2ee 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -12,6 +12,7 @@ use App\Services\AuditLogger; use Illuminate\Support\Facades\DB; use App\Support\OddsStandardScopes; use App\Lottery\ConfigVersionStatus; +use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** 后台:赔率版本({@see odds_versions} / {@see odds_items}) */ @@ -115,6 +116,7 @@ final class OddsStreamService public function publish(OddsVersion $draft, AdminUser $admin, ?Request $request = null): void { + $this->validatePublishableDraft($draft); $before = $this->snapshotVersion($draft); DB::transaction(function () use ($draft, $admin): void { @@ -180,4 +182,73 @@ final class OddsStreamService 'items_count' => $v->items()->count(), ]; } + + private function validatePublishableDraft(OddsVersion $draft): void + { + $items = $draft->items()->orderBy('currency_code')->orderBy('play_code')->orderBy('prize_scope')->get(); + $allowedPlayCodes = array_fill_keys( + PlayType::query()->pluck('play_code')->all(), + true, + ); + $allowedCurrencyCodes = array_fill_keys( + Currency::query() + ->where('is_bettable', true) + ->where('is_enabled', true) + ->pluck('code') + ->map(fn (string $code) => strtoupper($code)) + ->all(), + true, + ); + $allowedScopes = array_fill_keys(OddsStandardScopes::SCOPE_KEYS, true); + + $errors = []; + $seenKeys = []; + + if ($items->isEmpty()) { + $errors['items'][] = '草稿至少需要一条赔率配置'; + } + + foreach ($items as $index => $row) { + $playCode = (string) $row->play_code; + $scope = (string) $row->prize_scope; + $currencyCode = strtoupper((string) $row->currency_code); + $oddsValue = (int) $row->odds_value; + $rebateRate = (float) $row->rebate_rate; + $commissionRate = (float) $row->commission_rate; + $key = $playCode.'|'.$scope.'|'.$currencyCode; + + if (! isset($allowedPlayCodes[$playCode])) { + $errors["items.$index.play_code"][] = '玩法不存在'; + } + + if (! isset($allowedScopes[$scope])) { + $errors["items.$index.prize_scope"][] = '奖项档位不合法'; + } + + if (! isset($allowedCurrencyCodes[$currencyCode])) { + $errors["items.$index.currency_code"][] = '币种不可下注'; + } + + if (isset($seenKeys[$key])) { + $errors["items.$index"][] = '同一玩法、档位、币种存在重复赔率项'; + } + $seenKeys[$key] = true; + + if ($oddsValue <= 0) { + $errors["items.$index.odds_value"][] = '赔率值必须大于 0'; + } + + if ($rebateRate < 0 || $rebateRate > 1) { + $errors["items.$index.rebate_rate"][] = '返水比例必须在 0 到 1 之间'; + } + + if ($commissionRate < 0 || $commissionRate > 1) { + $errors["items.$index.commission_rate"][] = '佣金比例必须在 0 到 1 之间'; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } } diff --git a/app/Services/Config/PlayConfigStreamService.php b/app/Services/Config/PlayConfigStreamService.php index 01eea5e..03bdf65 100644 --- a/app/Services/Config/PlayConfigStreamService.php +++ b/app/Services/Config/PlayConfigStreamService.php @@ -10,6 +10,7 @@ use App\Models\PlayConfigItem; use App\Models\PlayConfigVersion; use Illuminate\Support\Facades\DB; use App\Lottery\ConfigVersionStatus; +use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** 后台:玩法配置版本({@see play_config_versions} / {@see play_config_items}) */ @@ -113,6 +114,7 @@ final class PlayConfigStreamService public function publish(PlayConfigVersion $draft, AdminUser $admin, ?Request $request = null): void { + $this->validatePublishableDraft($draft); $before = $this->snapshotVersion($draft); DB::transaction(function () use ($draft, $admin): void { @@ -178,4 +180,51 @@ final class PlayConfigStreamService 'items_count' => $v->items()->count(), ]; } + + private function validatePublishableDraft(PlayConfigVersion $draft): void + { + $items = $draft->items()->orderBy('play_code')->get(); + $allowedPlayCodes = array_fill_keys( + PlayType::query()->pluck('play_code')->all(), + true, + ); + + $errors = []; + $seenPlayCodes = []; + + if ($items->isEmpty()) { + $errors['items'][] = '草稿至少需要一条玩法配置'; + } + + foreach ($items as $index => $row) { + $playCode = (string) $row->play_code; + $minBet = (int) $row->min_bet_amount; + $maxBet = (int) $row->max_bet_amount; + + if (! isset($allowedPlayCodes[$playCode])) { + $errors["items.$index.play_code"][] = '玩法不存在'; + } + + if (isset($seenPlayCodes[$playCode])) { + $errors["items.$index.play_code"][] = '玩法重复'; + } + $seenPlayCodes[$playCode] = true; + + if ($minBet < 0) { + $errors["items.$index.min_bet_amount"][] = '最小下注额不能小于 0'; + } + + if ($maxBet < 0) { + $errors["items.$index.max_bet_amount"][] = '最大下注额不能小于 0'; + } + + if ($maxBet < $minBet) { + $errors["items.$index.max_bet_amount"][] = '最大下注额不能小于最小下注额'; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } } diff --git a/app/Services/Config/RiskCapStreamService.php b/app/Services/Config/RiskCapStreamService.php index 31948e6..67b8a2e 100644 --- a/app/Services/Config/RiskCapStreamService.php +++ b/app/Services/Config/RiskCapStreamService.php @@ -9,6 +9,7 @@ use App\Services\AuditLogger; use App\Models\RiskCapVersion; use Illuminate\Support\Facades\DB; use App\Lottery\ConfigVersionStatus; +use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** 后台:风控封顶版本({@see risk_cap_versions} / {@see risk_cap_items}) */ @@ -97,6 +98,7 @@ final class RiskCapStreamService public function publish(RiskCapVersion $draft, AdminUser $admin, ?Request $request = null): void { + $this->validatePublishableDraft($draft); $before = $this->snapshotVersion($draft); DB::transaction(function () use ($draft, $admin): void { @@ -162,4 +164,39 @@ final class RiskCapStreamService 'items_count' => $v->items()->count(), ]; } + + private function validatePublishableDraft(RiskCapVersion $draft): void + { + $items = $draft->items()->orderBy('draw_id')->orderBy('normalized_number')->get(); + $errors = []; + $seenKeys = []; + + if ($items->isEmpty()) { + $errors['items'][] = '草稿至少需要一条封顶配置'; + } + + foreach ($items as $index => $row) { + $normalizedNumber = (string) $row->normalized_number; + $capAmount = (int) $row->cap_amount; + $drawId = $row->draw_id === null ? '__null__' : (string) $row->draw_id; + $key = $drawId.'|'.$normalizedNumber; + + if (! preg_match('/^[0-9]{4}$/', $normalizedNumber)) { + $errors["items.$index.normalized_number"][] = '号码必须是 4 位数字'; + } + + if ($capAmount <= 0) { + $errors["items.$index.cap_amount"][] = '封顶金额必须大于 0'; + } + + if (isset($seenKeys[$key])) { + $errors["items.$index"][] = '同一期号与号码存在重复封顶配置'; + } + $seenKeys[$key] = true; + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } } diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 77c5127..287f98a 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -65,9 +65,22 @@ final class TicketPlacementService $totalRebate = 0; $totalActualDeduct = 0; $totalEstimatedPayout = 0; + $closedPlayCleanupRows = []; - foreach ((array) $payload['lines'] as $line) { - $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); + foreach ((array) $payload['lines'] as $index => $line) { + try { + $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); + } catch (TicketOperationException $e) { + if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) { + $closedPlayCleanupRows[] = [ + 'client_line_no' => $index + 1, + 'play_code' => (string) ($line['play_code'] ?? ''), + ]; + continue; + } + + throw $e; + } $evaluated = $this->ruleEngine->evaluateLine( (array) $line, $resolved['play_type'], @@ -89,6 +102,18 @@ final class TicketPlacementService $totalEstimatedPayout += (int) $evaluated['estimated_max_payout']; } + if ($closedPlayCleanupRows !== []) { + throw new TicketOperationException( + 'play_closed_need_cleanup', + ErrorCode::PlayModeClosed->value, + 400, + [ + 'cleanup_hint' => '玩法已关闭,相关注项已清理', + 'cleanup_lines' => $closedPlayCleanupRows, + ], + ); + } + $order = TicketOrder::query()->create([ 'order_no' => $this->newOrderNo(), 'player_id' => $player->id, diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php index 74e334a..57888ef 100644 --- a/app/Services/Ticket/TicketPreviewService.php +++ b/app/Services/Ticket/TicketPreviewService.php @@ -36,9 +36,22 @@ final class TicketPreviewService $totalActualDeduct = 0; $totalEstimatedPayout = 0; $warningRows = []; + $closedPlayCleanupRows = []; foreach ((array) $payload['lines'] as $index => $line) { - $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); + try { + $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); + } catch (TicketOperationException $e) { + if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) { + $closedPlayCleanupRows[] = [ + 'client_line_no' => $index + 1, + 'play_code' => (string) ($line['play_code'] ?? ''), + ]; + continue; + } + + throw $e; + } $evaluated = $this->ruleEngine->evaluateLine( (array) $line, $resolved['play_type'], @@ -83,6 +96,18 @@ final class TicketPreviewService ]; } + if ($closedPlayCleanupRows !== []) { + throw new TicketOperationException( + 'play_closed_need_cleanup', + ErrorCode::PlayModeClosed->value, + 400, + [ + 'cleanup_hint' => '玩法已关闭,相关注项已清理', + 'cleanup_lines' => $closedPlayCleanupRows, + ], + ); + } + return [ 'draw' => [ 'draw_id' => $draw->draw_no, diff --git a/tests/Feature/OperationalConfigApiTest.php b/tests/Feature/OperationalConfigApiTest.php index a847ee8..1c5d797 100644 --- a/tests/Feature/OperationalConfigApiTest.php +++ b/tests/Feature/OperationalConfigApiTest.php @@ -9,6 +9,7 @@ use App\Lottery\ConfigVersionStatus; use App\Lottery\ErrorCode; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Database\Seeders\OperationalConfigV1Seeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -35,6 +36,19 @@ function mintConfigAdminToken(): string return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } +function oddsPutPayloadFromDetail(array $items): array +{ + return collect($items)->map(fn (array $r) => [ + 'play_code' => $r['play_code'], + 'prize_scope' => $r['prize_scope'], + 'odds_value' => (int) $r['odds_value'], + 'rebate_rate' => (float) $r['rebate_rate'], + 'commission_rate' => (float) $r['commission_rate'], + 'currency_code' => $r['currency_code'], + 'extra_config_json' => $r['extra_config_json'] ?? null, + ])->all(); +} + test('play effective catalog is public and merged', function (): void { $resp = $this->getJson('/api/v1/play/effective?currency=NPR'); $resp->assertOk()->assertJsonPath('data.currency_code', 'NPR'); @@ -86,6 +100,23 @@ test('admin play config draft publish flow', function (): void { expect(PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1); }); +test('admin play config publish rejects empty draft items', function (): void { + $token = mintConfigAdminToken(); + $create = $this->postJson('/api/v1/admin/config/play-versions', [ + 'reason' => 'empty draft', + ], ['Authorization' => 'Bearer '.$token]); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + DB::table('play_config_items')->where('version_id', $draftId)->delete(); + + $this->postJson( + '/api/v1/admin/config/play-versions/'.$draftId.'/publish', + [], + ['Authorization' => 'Bearer '.$token], + )->assertStatus(422); +}); + test('admin play-types requires authentication', function (): void { $this->getJson('/api/v1/admin/play-types')->assertUnauthorized(); }); @@ -151,3 +182,24 @@ test('admin can delete draft risk cap version', function (): void { expect(RiskCapVersion::query()->whereKey($draftId)->exists())->toBeFalse(); }); + +test('admin odds config rejects out of range rebate rate', function (): void { + $token = mintConfigAdminToken(); + $create = $this->postJson('/api/v1/admin/config/odds-versions', [ + 'reason' => 'rate bound', + ], ['Authorization' => 'Bearer '.$token]); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, [ + 'Authorization' => 'Bearer '.$token, + ])->assertOk()->json('data.items'); + $payload = oddsPutPayloadFromDetail($detail); + $payload[0]['rebate_rate'] = 1.2; + + $this->putJson( + '/api/v1/admin/config/odds-versions/'.$draftId.'/items', + ['items' => $payload], + ['Authorization' => 'Bearer '.$token], + )->assertStatus(422); +}); diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 7fa594f..3d1da19 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -255,11 +255,45 @@ test('ticket place rejects disabled play from active catalog', function (): void ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::PlayModeClosed->value) - ->assertJsonPath('msg', __('wallet.2002', [], 'zh')); + ->assertJsonPath('msg', __('wallet.2002', [], 'zh')) + ->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理') + ->assertJsonPath('data.cleanup_lines.0.client_line_no', 1) + ->assertJsonPath('data.cleanup_lines.0.play_code', 'big'); expect(TicketOrder::query()->count())->toBe(0); }); +test('ticket preview returns cleanup hint when draft contains closed play', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $versionId = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->value('id'); + expect($versionId)->not->toBeNull(); + PlayConfigItem::query() + ->where('version_id', $versionId) + ->where('play_code', 'big') + ->update(['is_enabled' => false]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->withHeader('X-Locale', 'zh') + ->postJson('/api/v1/ticket/preview', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-preview-closed-play', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::PlayModeClosed->value) + ->assertJsonPath('msg', __('wallet.2002', [], 'zh')) + ->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理') + ->assertJsonPath('data.cleanup_lines.0.client_line_no', 1) + ->assertJsonPath('data.cleanup_lines.0.play_code', 'big'); +}); + test('ticket place rejects bet amount below configured minimum', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw();