fix: 增强配置发布校验与关闭玩法清理提示
1. 发布赔率、玩法配置和风控封顶草稿前校验空配置、重复项、金额范围和合法性 2. 限制赔率返水与佣金比例在 0 到 1 之间 3. 投注预览和下单遇到已关闭玩法时返回需清理注项明细
This commit is contained in:
@@ -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'],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user