Files
lotteryLaravel/tests/Feature/TicketBettingApiTest.php
kang f7f6c58b02 fix: 增强配置发布校验与关闭玩法清理提示
1. 发布赔率、玩法配置和风控封顶草稿前校验空配置、重复项、金额范围和合法性
2. 限制赔率返水与佣金比例在 0 到 1 之间
3. 投注预览和下单遇到已关闭玩法时返回需清理注项明细
2026-05-16 09:54:47 +08:00

422 lines
15 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\RiskPool;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\OddsVersion;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function ticketPlayerWithWallet(int $balance = 200_000): Player
{
$uniq = bin2hex(random_bytes(5));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ticket-player-'.$uniq,
'username' => 'tp_'.$uniq,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => $balance,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}
function ticketOpenDraw(string $drawNo = '20260511-001'): Draw
{
return Draw::query()->create([
'draw_no' => $drawNo,
'business_date' => '2026-05-11',
'sequence_no' => 1,
'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,
]);
}
function ticketPreviewPayload(string $drawNo = '20260511-001'): array
{
return [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'trace-001',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
[
'number' => '1234',
'play_code' => 'ibox',
'amount' => 100,
],
],
];
}
test('ticket preview returns computed summary for open draw', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400)
->assertJsonPath('data.config_versions.play_config_version_no', 1)
->assertJsonPath('data.config_versions.odds_version_no', 1)
->assertJsonPath('data.config_versions.risk_cap_version_no', 1)
->assertJsonCount(2, 'data.lines');
});
test('ticket place deducts wallet and persists order items combinations and logs', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400);
expect(TicketOrder::query()->count())->toBe(1);
expect(TicketItem::query()->count())->toBe(2);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
expect(RiskPool::query()->count())->toBeGreaterThan(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});
test('ticket place rejects closed draw', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$draw->update(['status' => DrawStatus::Closed->value]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
test('ticket place rejects insufficient balance', function (): void {
$player = ticketPlayerWithWallet(1_000);
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);
expect(TicketOrder::query()->count())->toBe(0);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket place succeeds when expected_config_versions matches preview', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versions = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $versions]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
expect(TicketOrder::query()->count())->toBe(1);
});
test('ticket place rejects stale expected_config_versions', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$preview = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->update(['version_no' => 99]);
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $preview]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetConfigStale->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects sold out risk pool', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'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-002',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects disabled play from active catalog', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versionId = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->value('id');
expect($versionId)->not->toBeNull();
PlayConfigItem::query()
->where('version_id', $versionId)
->where('play_code', 'big')
->update(['is_enabled' => false]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->withHeader('X-Locale', 'zh')
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-disabled-play',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
->assertJsonPath('msg', __('wallet.2002', [], 'zh'))
->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理')
->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
->assertJsonPath('data.cleanup_lines.0.play_code', 'big');
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket preview returns cleanup hint when draft contains closed play', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versionId = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->value('id');
expect($versionId)->not->toBeNull();
PlayConfigItem::query()
->where('version_id', $versionId)
->where('play_code', 'big')
->update(['is_enabled' => false]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->withHeader('X-Locale', 'zh')
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-preview-closed-play',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
->assertJsonPath('msg', __('wallet.2002', [], 'zh'))
->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理')
->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
->assertJsonPath('data.cleanup_lines.0.play_code', 'big');
});
test('ticket place rejects bet amount below configured minimum', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-min-bet',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 50],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::WalletAmountExceedsLimit->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket preview rejects invalid line amount per validation rules', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-invalid-amt',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 0],
],
])
->assertStatus(422);
});
/**
* §13.5 风险占用回滚同一订单两行共享号码时preview 各自通过;首条 acquire 后剩余不足则第二条失败,整单事务回滚。
* 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 {
$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,
]);
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-rollback',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
];
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
expect(TicketOrder::query()->count())->toBe(0);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000);
$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);
});
/** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */
test('ticket place sold out for second player after first consumes shared pool', function (): void {
$draw = ticketOpenDraw();
$playerA = ticketPlayerWithWallet();
$playerB = ticketPlayerWithWallet();
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,
]);
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-race-a',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
];
$this->withHeader('Authorization', 'Bearer dev:'.$playerA->id)
->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-a']))
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
$this->withHeader('Authorization', 'Bearer dev:'.$playerB->id)
->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-b']))
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
expect(TicketOrder::query()->count())->toBe(1);
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', '1234')
->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(2000);
});