diff --git a/app/Models/OddsItem.php b/app/Models/OddsItem.php index 8a9f18d..bed9166 100644 --- a/app/Models/OddsItem.php +++ b/app/Models/OddsItem.php @@ -16,6 +16,7 @@ final class OddsItem extends Model 'version_id', 'play_code', 'prize_scope', + 'dimension', 'odds_value', 'rebate_rate', 'commission_rate', @@ -27,6 +28,7 @@ final class OddsItem extends Model { return [ 'version_id' => 'integer', + 'dimension' => 'integer', 'odds_value' => 'integer', 'rebate_rate' => 'decimal:4', 'commission_rate' => 'decimal:4', diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index 281c97c..852ed69 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -62,6 +62,7 @@ final class OddsStreamService 'version_id' => $draft->id, 'play_code' => $row->play_code, 'prize_scope' => $row->prize_scope, + 'dimension' => $row->dimension, 'odds_value' => $row->odds_value, 'rebate_rate' => $row->rebate_rate, 'commission_rate' => $row->commission_rate, @@ -77,6 +78,7 @@ final class OddsStreamService 'version_id' => $draft->id, 'play_code' => $pt->play_code, 'prize_scope' => $scope, + 'dimension' => $pt->dimension, 'odds_value' => $oddsValue, 'rebate_rate' => 0, 'commission_rate' => 0, @@ -107,6 +109,7 @@ final class OddsStreamService 'version_id' => $draft->id, 'play_code' => (string) $row['play_code'], 'prize_scope' => (string) $row['prize_scope'], + 'dimension' => isset($row['dimension']) ? (int) $row['dimension'] : null, 'odds_value' => (int) $row['odds_value'], 'rebate_rate' => (float) ($row['rebate_rate'] ?? 0), 'commission_rate' => (float) ($row['commission_rate'] ?? 0), @@ -221,6 +224,7 @@ final class OddsStreamService foreach ($items as $index => $row) { $playCode = (string) $row->play_code; $scope = (string) $row->prize_scope; + $dimension = $row->dimension; $currencyCode = strtoupper((string) $row->currency_code); $oddsValue = (int) $row->odds_value; $rebateRate = (float) $row->rebate_rate; @@ -239,6 +243,10 @@ final class OddsStreamService $errors["items.$index.currency_code"][] = '币种不可下注'; } + if ($dimension !== null && ! in_array($dimension, [2, 3, 4], true)) { + $errors["items.$index.dimension"][] = '维度必须是 2、3 或 4'; + } + if (isset($seenKeys[$key])) { $errors["items.$index"][] = '同一玩法、档位、币种存在重复赔率项'; } diff --git a/app/Services/Ticket/PlayRuleEngine.php b/app/Services/Ticket/PlayRuleEngine.php index e416917..42933dd 100644 --- a/app/Services/Ticket/PlayRuleEngine.php +++ b/app/Services/Ticket/PlayRuleEngine.php @@ -48,9 +48,11 @@ final class PlayRuleEngine $unitBetAmount = $this->resolveUnitBetAmount($playCode, $amount, $combinationCount); $totalBetAmount = $this->resolveTotalBetAmount($playCode, $amount, $unitBetAmount, $combinationCount); + $dimensionInt = $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig); $primaryOdds = $this->pickPrimaryOdds($oddsItems); $rebateRate = (float) $primaryOdds->rebate_rate; - $commissionRate = (float) $primaryOdds->commission_rate; + // 佣金按维度(2D/3D/4D)配置:从 odds_items 中查找匹配维度的佣金率 + $commissionRate = $this->pickCommissionByDimension($oddsItems, $dimensionInt); $actualDeductAmount = max(0, (int) floor($totalBetAmount * (1 - $rebateRate))); $maxOdds = $oddsItems->max(fn (OddsItem $row) => (int) $row->odds_value) ?? 0; $estimatedPayoutPerCombo = (int) floor($unitBetAmount * ($maxOdds / 10000)); @@ -302,6 +304,28 @@ final class PlayRuleEngine return $oddsItems->firstOrFail(); } + /** + * 按维度(2D/3D/4D)获取佣金率 + * + * @param Collection $oddsItems + */ + private function pickCommissionByDimension(Collection $oddsItems, ?int $dimension): float + { + if ($dimension === null) { + // 如果维度为空,返回第一个 odds item 的佣金率(向后兼容) + return (float) ($oddsItems->first()?->commission_rate ?? 0); + } + + // 查找匹配维度的 odds item + $dimensionItem = $oddsItems->firstWhere('dimension', $dimension); + if ($dimensionItem !== null) { + return (float) $dimensionItem->commission_rate; + } + + // 如果没有找到匹配维度的,返回第一个的佣金率(向后兼容) + return (float) ($oddsItems->first()?->commission_rate ?? 0); + } + private function toDimensionInt(?string $dimension, PlayConfigItem $playConfig): ?int { return match ($dimension) { diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 9a204be..8ef95c1 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -38,14 +38,14 @@ final class AdminAuthorizationRegistry ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'risk.monitor.view']], ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.review.publish']], - ['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.play.manage']], - ['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.odds.manage']], - ['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.risk_cap.manage']], - ['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看', 'nav_segment' => 'config', 'permission_codes' => ['config.risk_cap.view']], - ['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.odds.manage']], - ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'nav_segment' => 'config', 'permission_codes' => ['config.odds.manage']], - ['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.jackpot.manage']], - ['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看', 'nav_segment' => 'config', 'permission_codes' => ['config.jackpot.view']], + ['slug' => 'prd.play_switch.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.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.manage']], + ['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.view']], + ['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], + ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], + ['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.manage']], + ['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.view']], ['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.view']], ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']], @@ -73,10 +73,12 @@ final class AdminAuthorizationRegistry 'currencies' => '币种管理', 'wallet' => '钱包流水', 'draws' => '期号列表', - 'config' => '运营配置', + 'rules_plays' => '投注规则', + 'rules_odds' => '赔率与回水', + 'risk_cap' => '限额版本', 'risk' => '风控', 'settlement' => '结算', - 'jackpot' => 'Jackpot', + 'jackpot' => '奖池', 'reconcile' => '对账', 'tickets' => '玩家注单', 'audit' => '审计日志', @@ -106,18 +108,26 @@ final class AdminAuthorizationRegistry public static function navigationDefinitions(): array { return [ + // 总览 ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin'], - ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']], - ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']], - ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], - ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']], - ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']], + // 日常运营:开奖 → 注单 → 玩家 ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], - ['segment' => 'config', 'label' => 'Configuration', 'href' => '/admin/config', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view']], - ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']], + ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.users.view_cs', 'prd.users.manage', 'prd.users.view_finance', 'prd.draw_result.view', 'prd.draw_result.manage', 'prd.payout.view', 'prd.payout.review', 'prd.payout.manage']], + ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], + // 规则与参数 + ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage']], + ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], + ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], + ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], + // 资金 + ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']], ['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], - ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.users.view_cs', 'prd.users.manage', 'prd.users.view_finance', 'prd.draw_result.view', 'prd.draw_result.manage', 'prd.payout.view', 'prd.payout.review', 'prd.payout.manage']], + ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']], + // 权限与系统 + ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']], + ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']], + ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']], ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance']], ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']], ]; @@ -179,7 +189,10 @@ final class AdminAuthorizationRegistry 'currencies' => ['prd.currency.manage'], 'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'], 'draws' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage'], - 'config' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view'], + 'rules_plays' => ['prd.play_switch.manage', 'prd.odds.manage'], + 'rules_odds' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view'], + 'jackpot' => ['prd.jackpot.manage', 'prd.jackpot.view'], + 'risk_cap' => ['prd.risk_cap.manage', 'prd.risk_cap.view'], 'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'], 'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'], 'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], diff --git a/app/Support/OddsStandardScopes.php b/app/Support/OddsStandardScopes.php index 032301e..e61124e 100644 --- a/app/Support/OddsStandardScopes.php +++ b/app/Support/OddsStandardScopes.php @@ -30,6 +30,8 @@ final class OddsStandardScopes * * 仅对「该版本里已出现过的 (play_code, currency_code)」补全,避免给从未配置过的玩法凭空加行。 * 若版本下无任何 odds_items,则用当前全部玩法 × 首个可下注币种补全。 + * + * 佣金按维度(2D/3D/4D)配置:同一维度的所有玩法共享佣金率。 */ public static function syncMissingForVersion(OddsVersion $version): void { @@ -54,26 +56,45 @@ final class OddsStandardScopes $pairs = PlayType::query() ->orderBy('sort_order') ->orderBy('play_code') - ->get(['play_code']) + ->get(['play_code', 'dimension']) ->map(fn (PlayType $pt) => (object) [ 'play_code' => $pt->play_code, + 'dimension' => $pt->dimension, 'currency_code' => $currencyCode, ]); } + // 按维度分组,获取每个维度的佣金率 + $dimensionCommissions = []; + foreach ($pairs as $pair) { + $dimension = $pair->dimension ?? null; + $currencyCode = strtoupper((string) $pair->currency_code); + $key = $dimension.'|'.$currencyCode; + + if (!isset($dimensionCommissions[$key])) { + // 从现有记录中获取该维度的佣金率 + $anchor = OddsItem::query() + ->where('version_id', $vid) + ->where('currency_code', $currencyCode) + ->where('dimension', $dimension) + ->orderByDesc('id') + ->first(); + + $dimensionCommissions[$key] = [ + 'rebate' => (float) ($anchor?->rebate_rate ?? 0), + 'commission' => (float) ($anchor?->commission_rate ?? 0), + ]; + } + } + foreach ($pairs as $pair) { $playCode = (string) $pair->play_code; + $dimension = $pair->dimension ?? null; $currencyCode = strtoupper((string) $pair->currency_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); + $key = $dimension.'|'.$currencyCode; + + $rebate = $dimensionCommissions[$key]['rebate'] ?? 0; + $commission = $dimensionCommissions[$key]['commission'] ?? 0; foreach (self::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) { $exists = OddsItem::query() @@ -90,6 +111,7 @@ final class OddsStandardScopes 'version_id' => $vid, 'play_code' => $playCode, 'prize_scope' => $scope, + 'dimension' => $dimension, 'odds_value' => $oddsValue, 'rebate_rate' => $rebate, 'commission_rate' => $commission, diff --git a/database/migrations/2026_05_21_093141_add_dimension_to_odds_items_table.php b/database/migrations/2026_05_21_093141_add_dimension_to_odds_items_table.php new file mode 100644 index 0000000..51f5fb1 --- /dev/null +++ b/database/migrations/2026_05_21_093141_add_dimension_to_odds_items_table.php @@ -0,0 +1,49 @@ +unsignedTinyInteger('dimension')->nullable()->after('prize_scope')->comment('2/3/4 维度,佣金按维度配置'); + + // 删除旧的唯一约束 + $table->dropUnique('uk_odds_items_version_play_prize_currency'); + + // 添加新的唯一约束:佣金按 dimension + currency_code 配置 + // 赔率仍按 play_code + prize_scope + currency_code 配置 + $table->unique( + ['version_id', 'play_code', 'prize_scope', 'currency_code', 'dimension'], + 'uk_odds_items_version_play_prize_currency_dimension' + ); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('odds_items', function (Blueprint $table) { + // 删除新的唯一约束 + $table->dropUnique('uk_odds_items_version_play_prize_currency_dimension'); + + // 恢复旧的唯一约束 + $table->unique( + ['version_id', 'play_code', 'prize_scope', 'currency_code'], + 'uk_odds_items_version_play_prize_currency' + ); + + // 删除 dimension 字段 + $table->dropColumn('dimension'); + }); + } +}; diff --git a/database/seeders/OperationalConfigV1Seeder.php b/database/seeders/OperationalConfigV1Seeder.php index 5f3eb98..a1e439d 100644 --- a/database/seeders/OperationalConfigV1Seeder.php +++ b/database/seeders/OperationalConfigV1Seeder.php @@ -105,6 +105,7 @@ final class OperationalConfigV1Seeder extends Seeder 'version_id' => $oddsVersion->id, 'play_code' => $pt->play_code, 'prize_scope' => $scope, + 'dimension' => $pt->dimension, 'odds_value' => $oddsValue, 'rebate_rate' => 0, 'commission_rate' => 0,