feat: 更新玩法配置管理,简化字段并增强功能

- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。
- 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。
- 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
2026-05-25 14:34:24 +08:00
parent 270d2e9af1
commit e27a00f260
74 changed files with 4469 additions and 280 deletions

View File

@@ -14,6 +14,7 @@ use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\TicketOrder;
use App\Models\OddsItem;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -413,18 +414,238 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
}
});
test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void {
$cases = [
[
'play' => 'pos_3abc',
'number' => '234',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '1234',
'second' => '5234',
'third' => '9234',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'first',
],
[
'play' => 'pos_3abc',
'number' => '234',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '1567',
'second' => '5234',
'third' => '8234',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'second',
],
[
'play' => 'pos_2abc',
'number' => '99',
'board' => fn (string $t, int $i): string => match ($t) {
'first' => '8899',
'second' => '2299',
'third' => '1199',
default => p145_board_without_8888($t, $i),
},
'expected_tier' => 'first',
],
];
foreach ($cases as $case) {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true),
'lines' => [
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000);
p145_publish_board($draw, $case['board']);
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
expect($item->status)->toBe('settled_win', $case['play'])
->and((int) $item->win_amount)->toBe($expectedWin, $case['play'])
->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']);
}
});
test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-ibox-multi-tier',
'lines' => [
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$deduct = (int) $item->actual_deduct_amount;
expect($deduct)->toBe(600)
->and((int) $item->combination_count)->toBe(6);
$unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
$unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000);
$expectedWin = $unitWinFirst + $unitWinStarter;
p145_publish_board($draw, function (string $t, int $i): string {
return match ($t) {
'first' => '1212',
'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i),
default => p145_board_without_8888($t, $i),
};
});
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
$matchLines = is_array($detail->match_detail_json)
? ($detail->match_detail_json['lines'] ?? [])
: [];
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWin)
->and($detail->matched_prize_tier)->toBe('first')
->and(count($matchLines))->toBe(2);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin);
});
test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$rawAmount = 10_001;
$comboCount = 24;
$unitBet = intdiv($rawAmount, $comboCount);
$expectedDeduct = $unitBet * $comboCount;
$expectedRemainder = $rawAmount - $expectedDeduct;
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-mbox-remainder-win',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
expect((int) $item->combination_count)->toBe($comboCount)
->and((int) $item->unit_bet_amount)->toBe($unitBet)
->and((int) $item->total_bet_amount)->toBe($expectedDeduct)
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
$expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWin);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct + $expectedWin);
});
test('module 6 mbox remainder is not refunded on losing settlement', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$rawAmount = 10_001;
$expectedDeduct = intdiv($rawAmount, 24) * 24;
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'module6-mbox-remainder-lose',
'lines' => [
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct);
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
expect($item->status)->toBe('settled_lose')
->and((int) $item->win_amount)->toBe(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct);
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
});
test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void {
JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 0,
'contribution_rate' => '0.1000',
'trigger_threshold' => 1,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
]);
JackpotPool::query()->updateOrCreate(
['currency_code' => 'NPR'],
[
'current_amount' => 0,
'contribution_rate' => '0.1000',
'trigger_threshold' => 1,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
],
);
$player = p145_player();
$drawNo = p145_next_draw_no();
@@ -515,6 +736,117 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
expect((int) $pool->locked_amount)->toBe(3000);
});
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-odds-snapshot-1',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertOk();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
$snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [])
->firstWhere('prize_scope', 'first');
expect($snapshotOdds)->not->toBeNull();
// 修改当前赔率配置:如果结算错误使用“实时配置”,这里会导致派奖金额变化。
OddsItem::query()
->where('play_code', 'big')
->where('prize_scope', 'first')
->where('currency_code', 'NPR')
->update(['odds_value' => 10_000]);
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$item->refresh();
$expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000));
expect($item->status)->toBe('settled_win')
->and((int) $item->win_amount)->toBe($expectedWinBySnapshot);
});
test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void {
$player = p145_player(80_000_000);
$drawNo = p145_next_draw_no();
$draw = p145_draw($drawNo, random_int(1, 99_999));
// 下注一单,确保产生风险池占用。
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-risk-release-1',
'lines' => [
['number' => '8888', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk();
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail();
$cap = (int) $pool->total_cap_amount;
expect((int) $pool->locked_amount)->toBeGreaterThan(0)
->and((int) $pool->remaining_amount)->toBeLessThan($cap);
// 先走未中奖结算,验证释放。
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
p145_approve_and_payout($draw);
$poolAfterLose = $pool->fresh();
expect((int) $poolAfterLose->locked_amount)->toBe(0)
->and((int) $poolAfterLose->remaining_amount)->toBe($cap);
// 再开一盘中奖结算,验证同样释放。
$drawNo2 = p145_next_draw_no();
$draw2 = p145_draw($drawNo2, random_int(1, 99_999));
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => $drawNo2,
'currency_code' => 'NPR',
'client_trace_id' => 'p145-risk-release-2',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk();
$pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail();
$cap2 = (int) $pool2->total_cap_amount;
expect((int) $pool2->locked_amount)->toBeGreaterThan(0);
p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
$draw2->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue();
p145_approve_and_payout($draw2);
$poolAfterWin = $pool2->fresh();
expect((int) $poolAfterWin->locked_amount)->toBe(0)
->and((int) $poolAfterWin->remaining_amount)->toBe($cap2);
});
/**
* 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。
* `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。