feat: 为赔率项增加 dimension 字段并支持按维度配置佣金,调整后台配置导航权限

This commit is contained in:
2026-05-21 17:54:31 +08:00
parent 7a6048de10
commit c1c25e3143
7 changed files with 150 additions and 31 deletions

View File

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

View File

@@ -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"][] = '同一玩法、档位、币种存在重复赔率项';
}

View File

@@ -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<int, OddsItem> $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) {

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('odds_items', function (Blueprint $table) {
// 添加 dimension 字段用于按 2D/3D/4D 配置佣金
$table->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');
});
}
};

View File

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