Files
lotteryLaravel/tests/Feature/JackpotPlacementSettlementTest.php
kang 6b2ea39ea1
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
fix: 修复并发竞态与幂等性缺陷并优化子树查询
- 扩大玩家ID随机数位宽降低碰撞概率
- 授信额度变更加 lockForUpdate 防并发下调
- 结算冲正与入账利用唯一索引实现幂等闸门
- 失败登录计数加锁防丢失
- 代理子树查询改用子查询替代全表 pluck
- 修复返点默认值百分比单位转换
- 测试中全量 fake 广播事件防 CI 500
2026-06-18 09:38:24 +08:00

516 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\OddsVersion;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\JackpotPayoutLog;
use App\Models\SettlementBatch;
use Illuminate\Support\Facades\Event;
use App\Events\JackpotBurstBroadcast;
use App\Services\Draw\DrawResultViewService;
use App\Models\JackpotContribution;
use Illuminate\Support\Facades\Hash;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementBatchWorkflowService;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function jackpotTestPlayer(string $prefix = 'jp'): Player
{
$uniq = bin2hex(random_bytes(4));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => $prefix.'-p-'.$uniq,
'username' => $prefix.'_'.$uniq,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 5_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}
function jackpotOpenDraw(string $drawNo): Draw
{
return Draw::query()->create([
'draw_no' => $drawNo,
'business_date' => '2026-05-11',
'sequence_no' => (int) substr($drawNo, -3),
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(2),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
}
/** 迁移已 seed 默认 NPR 池,测试内用 upsert 避免 UNIQUE(currency_code) 冲突 */
function jackpotUpsertPool(array $attrs): JackpotPool
{
$currencyCode = (string) ($attrs['currency_code'] ?? 'NPR');
return JackpotPool::query()->updateOrCreate(
['currency_code' => $currencyCode],
$attrs,
);
}
function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
{
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'rng_seed_hash' => 'test-'.(string) $draw->draw_no,
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$num = $slot['prize_type'] === 'first' ? $firstNumber : '5678';
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => $num,
'suffix_3d' => substr($num, -3),
'suffix_2d' => substr($num, -2),
'head_digit' => (int) substr($num, 0, 1),
'tail_digit' => (int) substr($num, 3, 1),
]);
}
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
}
test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void {
jackpotUpsertPool([
'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,
]);
$uniq = bin2hex(random_bytes(4));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'jp-p-'.$uniq,
'username' => 'jp_'.$uniq,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 5_000_000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => '20260511-901',
'business_date' => '2026-05-11',
'sequence_no' => 901,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(2),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-901',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-trace-1',
'lines' => [
['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000],
],
])
->assertOk();
expect(JackpotContribution::query()->count())->toBe(1);
$poolAfterBet = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
expect((int) $poolAfterBet->current_amount)->toBe(1_000);
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => 1,
'source_type' => 'rng',
'rng_seed_hash' => 'test',
'raw_seed_encrypted' => null,
'status' => DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$num = $slot['prize_type'] === 'first' ? '1234' : '5678';
$suffix3 = substr($num, -3);
$suffix2 = substr($num, -2);
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => $num,
'suffix_3d' => $suffix3,
'suffix_2d' => $suffix2,
'head_digit' => (int) substr($num, 0, 1),
'tail_digit' => (int) substr($num, 3, 1),
]);
}
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => 1,
])->save();
$ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
expect($ran)->toBeTrue();
$admin = AdminUser::query()->create([
'username' => 'jp_settle_admin',
'name' => 'JP Settle Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin);
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->win_amount)->toBe(250_000);
expect((int) $item->jackpot_win_amount)->toBe(1_000);
$poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
expect((int) $poolAfterSettle->current_amount)->toBe(0);
expect(JackpotPayoutLog::query()->count())->toBe(1);
$order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail();
expect($order->status)->toBe('settled');
});
test('jackpot contribution respects switch and minimum bet threshold', function (): void {
jackpotUpsertPool([
'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' => 20_000,
'status' => 1,
'last_trigger_draw_id' => null,
]);
$player = jackpotTestPlayer('jpmin');
jackpotOpenDraw('20260511-902');
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-902',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-min-1',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
expect(JackpotContribution::query()->count())->toBe(0);
JackpotPool::query()->where('currency_code', 'NPR')->update([
'min_bet_amount' => 0,
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-902',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-off-1',
'lines' => [['number' => '2234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
expect(JackpotContribution::query()->count())->toBe(0);
});
test('jackpot contribution uses total bet amount instead of actual deduct amount', function (): void {
jackpotUpsertPool([
'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' => 10_000,
'status' => 1,
'last_trigger_draw_id' => null,
]);
$player = jackpotTestPlayer('jprebate');
$draw = jackpotOpenDraw('20260511-902A');
$oddsVersionId = OddsVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->value('id');
expect($oddsVersionId)->not->toBeNull();
DB::table('odds_items')
->where('version_id', $oddsVersionId)
->where('play_code', 'straight')
->update(['rebate_rate' => 0.01]);
DB::table('player_rebate_profiles')->insert([
'player_id' => $player->id,
'game_type' => 'straight',
'rebate_rate' => 0.005,
'extra_rebate_rate' => 0.002,
'inherit_from_agent' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-902A',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-rebate-base',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk()
->assertJsonPath('data.summary.total_actual_deduct', 9_830);
$contribution = JackpotContribution::query()->firstOrFail();
$item = TicketItem::query()->firstOrFail();
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
expect((int) $item->total_bet_amount)->toBe(10_000)
->and((int) $item->actual_deduct_amount)->toBe(9_830)
->and((int) $contribution->contribution_amount)->toBe(1_000)
->and((int) $pool->current_amount)->toBe(1_000);
});
test('jackpot bursts by configured play combination trigger before threshold', function (): void {
// 该 case 切到 reverb driver必须 fake 流程中所有可能触发的 Broadcast 事件,
// 否则 fake 列表外的广播会真实连接 Reverb/Pusher在 CI 环境抛 500。
Event::fake(allBroadcastEvents());
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 50_000,
'contribution_rate' => '0.0000',
'trigger_threshold' => 999_999_999,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
'combo_trigger_play_codes' => ['straight'],
]);
$player = jackpotTestPlayer('jpcombo');
$draw = jackpotOpenDraw('20260511-903');
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-903',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-combo-1',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
jackpotPublishResults($draw, '1234');
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $item->jackpot_win_amount)->toBe(50_000)
->and(JackpotPayoutLog::query()->firstOrFail()->trigger_type)->toBe('play_combo')
->and((int) JackpotPool::query()->where('currency_code', 'NPR')->value('current_amount'))->toBe(0);
Event::assertDispatched(
JackpotBurstBroadcast::class,
fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id
&& $event->drawNo === '20260511-903'
&& $event->firstPrizeNumber === '1234'
&& $event->currencyCode === 'NPR'
&& $event->totalPayoutAmount === 50_000
&& $event->winnerCount === 1
&& $event->triggerType === 'play_combo'
&& $event->poolAmountAfter === 0,
);
});
test('jackpot splits burst payout between multiple winners by bet amount', function (): void {
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 90_000,
'contribution_rate' => '0.0000',
'trigger_threshold' => 1,
'payout_rate' => '1.0000',
'force_trigger_draw_gap' => 0,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
]);
$playerA = jackpotTestPlayer('jpa');
$playerB = jackpotTestPlayer('jpb');
$draw = jackpotOpenDraw('20260511-904');
$this->withHeader('Authorization', 'Bearer dev:'.$playerA->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-904',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-split-a',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
])
->assertOk();
$this->withHeader('Authorization', 'Bearer dev:'.$playerB->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-904',
'currency_code' => 'NPR',
'client_trace_id' => 'jp-split-b',
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 20_000]],
])
->assertOk();
jackpotPublishResults($draw, '1234');
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$amounts = TicketItem::query()
->where('draw_id', $draw->id)
->orderBy('total_bet_amount')
->pluck('jackpot_win_amount')
->map(fn ($v) => (int) $v)
->all();
expect($amounts)->toBe([30_000, 60_000]);
});
test('jackpot summary and result payload expose pool amount and draw gap', function (): void {
$last = Draw::query()->create([
'draw_no' => '20260511-800',
'business_date' => '2026-05-11',
'sequence_no' => 800,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(3),
'close_time' => now()->subHours(2),
'draw_time' => now()->subHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 1,
'is_reopened' => false,
]);
Draw::query()->create([
'draw_no' => '20260511-801',
'business_date' => '2026-05-11',
'sequence_no' => 801,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(2),
'close_time' => now()->subHour(),
'draw_time' => now()->subHour(),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 1,
'is_reopened' => false,
]);
jackpotUpsertPool([
'currency_code' => 'NPR',
'current_amount' => 123_456,
'contribution_rate' => '0.0100',
'trigger_threshold' => 1_000_000,
'payout_rate' => '0.5000',
'force_trigger_draw_gap' => 10,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => $last->id,
]);
$draw = jackpotOpenDraw('20260511-905');
jackpotPublishResults($draw, '1234');
$draw->forceFill(['status' => DrawStatus::Cooldown->value])->save();
$this->getJson('/api/v1/jackpot/summary?currency_code=NPR')
->assertOk()
->assertJsonPath('data.current_amount_minor', 123_456)
->assertJsonPath('data.draws_since_last_burst', 1);
$this->getJson('/api/v1/draw/results/20260511-905')
->assertOk()
->assertJsonPath('data.jackpot.current_amount_minor', 123_456)
->assertJsonPath('data.jackpot.draws_since_last_burst', 1);
$summary = app(DrawResultViewService::class)->summarizeDraw($draw->fresh());
expect($summary['jackpot']['current_amount_minor'] ?? null)->toBe(123_456);
});