fix: 增强配置发布校验与关闭玩法清理提示

1. 发布赔率、玩法配置和风控封顶草稿前校验空配置、重复项、金额范围和合法性
2. 限制赔率返水与佣金比例在 0 到 1 之间
3. 投注预览和下单遇到已关闭玩法时返回需清理注项明细
This commit is contained in:
2026-05-16 09:54:47 +08:00
parent 87637f2e4a
commit f7f6c58b02
8 changed files with 299 additions and 6 deletions

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -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,