feat: 支持开奖重开与风险池原子扣减,完善投注部分成功流程

This commit is contained in:
2026-05-18 11:28:11 +08:00
parent 4f143c7cb1
commit 9157dcb6a1
14 changed files with 526 additions and 103 deletions

View File

@@ -236,6 +236,24 @@ test('ticket place rejects closed draw', function (): void {
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
test('ticket preview and place reject open draw after server close time', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$draw->update(['close_time' => now()->subSecond()]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects insufficient balance', function (): void {
$player = ticketPlayerWithWallet(1_000);
ticketOpenDraw();
@@ -324,6 +342,58 @@ test('ticket place rejects sold out risk pool', function (): void {
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place can return mixed success and failed risk results', function (): void {
$player = ticketPlayerWithWallet(500_000);
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 0,
'remaining_amount' => 5000,
'sold_out_status' => 0,
'version' => 0,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '5678',
'total_cap_amount' => 100,
'locked_amount' => 100,
'remaining_amount' => 0,
'sold_out_status' => 1,
'version' => 1,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-mixed-risk',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
['number' => '5678', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.summary.failure_count', 1)
->assertJsonPath('data.items.0.status', 'success')
->assertJsonPath('data.items.1.status', 'failed')
->assertJsonPath('data.items.1.fail_reason_code', (string) ErrorCode::RiskPoolSoldOut->value);
$order = TicketOrder::query()->firstOrFail();
expect($order->status)->toBe('partial_failed')
->and((int) $order->total_actual_deduct)->toBe(120)
->and(TicketItem::query()->where('status', 'success')->count())->toBe(1)
->and(TicketItem::query()->where('status', 'failed')->count())->toBe(1);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000 - 120);
});
test('ticket place rejects disabled play from active catalog', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
@@ -424,10 +494,10 @@ test('ticket preview rejects invalid line amount per validation rules', function
});
/**
* §13.5 风险占用回滚同一订单两行共享号码时preview 各自通过;首条 acquire 后剩余不足则第二条失败,整单事务回滚
* §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败
* big + amount 120 estimated_payout 3000maxOdds 250000 / 10000 = 25)。
*/
test('ticket place rolls back order wallet and risk locks when mid-order acquire fails', function (): void {
test('ticket place records partial failed when mid-order acquire fails', function (): void {
$player = ticketPlayerWithWallet(500_000);
$draw = ticketOpenDraw();
@@ -453,21 +523,24 @@ test('ticket place rolls back order wallet and risk locks when mid-order acquire
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.summary.failure_count', 1);
expect(TicketOrder::query()->count())->toBe(0);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
expect(TicketOrder::query()->count())->toBe(1);
expect(TicketOrder::query()->firstOrFail()->status)->toBe('partial_failed');
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000);
expect((int) $wallet->balance)->toBe(500_000 - 120);
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', '1234')
->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(5000);
expect((int) $pool->locked_amount)->toBe(0);
expect((int) $pool->remaining_amount)->toBe(2000);
expect((int) $pool->locked_amount)->toBe(3000);
});
/** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */