feat: 添加新的错误码以支持配置版本管理,更新彩票配置以启用手动审核,增强 API 路由以支持玩法和赔率版本化管理

This commit is contained in:
2026-05-11 10:08:48 +08:00
parent aeaf124096
commit 067c2b39f5
41 changed files with 2578 additions and 1 deletions

View File

@@ -0,0 +1,321 @@
<?php
/**
* 验收自动化(对应测试任务 §5 / 完成标准 §12.6
*
* - 配置改动是否生效 赔率 / 玩法限额发布后 GET /api/v1/play/effective 可读出新值PATCH play-types 可改目录开关。
* - 配置改动是否影响已下注订单 ticket_items 行的 odds_snapshot_json 不因新赔率版本发布而被改写(注单链路尚未实现时,用数据不变量验证)。
* - 配置历史是否可追溯 odds_versions 存在 archived + activeaudit_logs 记录 publish。
*
* 运行php artisan test tests/Feature/OperationalConfigAcceptanceTest.php
*/
use App\Lottery\ConfigVersionStatus;
use App\Lottery\DrawStatus;
use App\Models\AdminUser;
use App\Models\AuditLog;
use App\Models\Draw;
use App\Models\OddsVersion;
use App\Models\Player;
use App\Models\PlayType;
use App\Models\RiskCapVersion;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Database\Seeders\PlayTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(PlayTypeSeeder::class);
$this->seed(OperationalConfigV1Seeder::class);
});
function acceptanceMintAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'acceptance_admin',
'name' => 'Acceptance QA',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function oddsPutPayloadFromDetail(array $items): array
{
return collect($items)->map(fn (array $r) => [
'play_code' => $r['play_code'],
'prize_scope' => $r['prize_scope'],
'odds_value' => (int) $r['odds_value'],
'rebate_rate' => (float) $r['rebate_rate'],
'commission_rate' => (float) $r['commission_rate'],
'currency_code' => $r['currency_code'],
'extra_config_json' => $r['extra_config_json'] ?? null,
])->all();
}
test('§12.6 published play limits are visible on public effective catalog without code deploy', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$itemPayload = [];
foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
$itemPayload[] = [
'play_code' => $t->play_code,
'is_enabled' => true,
'min_bet_amount' => 777,
'max_bet_amount' => 400_000_000,
'display_order' => (int) $t->sort_order,
];
}
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
expect($plays->firstWhere('play_code', 'big')['config']['min_bet_amount'])->toBe(777);
});
test('§12.6 published odds are visible on public effective catalog without code deploy', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'acceptance odds'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$payload = oddsPutPayloadFromDetail($detail);
foreach ($payload as &$row) {
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
$row['odds_value'] = 333_333;
}
}
unset($row);
$this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(333_333);
});
test('§5 odds publish archives prior version lists history and writes audit log', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$beforeArchived = OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'audit trail'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$this->putJson(
'/api/v1/admin/config/odds-versions/'.$draftId.'/items',
['items' => oddsPutPayloadFromDetail($detail)],
$auth,
)->assertOk();
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
expect(OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1);
expect(OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);
$list = $this->getJson('/api/v1/admin/config/odds-versions?per_page=50', $auth)->assertOk()->json('data.items');
expect(count($list))->toBeGreaterThanOrEqual(2);
expect(
AuditLog::query()
->where('module_code', 'odds')
->where('action_code', 'publish')
->exists(),
)->toBeTrue();
});
test('§5 existing ticket_items odds snapshot row is not mutated when new odds version publishes', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$snapshot = ['frozen_odds_first' => 250_000, 'note' => 'acceptance synthetic row'];
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'p1',
'username' => 'u1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => 'ACC-001',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => DrawStatus::Open->value,
'start_time' => null,
'close_time' => null,
'draw_time' => null,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$now = now();
$orderId = DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-ACC-001',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 0,
'status' => 'confirmed',
'submit_source' => 'h5',
'client_trace_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('ticket_items')->insert([
'ticket_no' => 'TICK-ACC-001',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => null,
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 2,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 100,
'total_bet_amount' => 100,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 100,
'odds_snapshot_json' => json_encode($snapshot),
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'confirmed',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'after ticket'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$payload = oddsPutPayloadFromDetail($detail);
foreach ($payload as &$row) {
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
$row['odds_value'] = 9_999_999;
}
}
unset($row);
$this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
$stored = DB::table('ticket_items')->where('ticket_no', 'TICK-ACC-001')->value('odds_snapshot_json');
expect(json_decode((string) $stored, true))->toBe($snapshot);
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(9_999_999);
});
test('§12.6 PATCH play-types controls master_enabled on public catalog without code deploy', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)->assertOk();
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
expect($plays->firstWhere('play_code', 'big')['master_enabled'])->toBeFalse();
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => true], $auth)->assertOk();
});
test('§5 risk cap publish is audited and version history exists', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$beforeArchived = RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();
$create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'acceptance risk'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$rows = $this->getJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$payload = collect($rows)->map(fn (array $r) => [
'draw_id' => $r['draw_id'],
'normalized_number' => $r['normalized_number'],
'cap_amount' => (int) $r['cap_amount'],
'cap_type' => $r['cap_type'],
])->all();
$this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk();
expect(RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);
expect(
AuditLog::query()
->where('module_code', 'risk_cap')
->where('action_code', 'publish')
->exists(),
)->toBeTrue();
$list = $this->getJson('/api/v1/admin/config/risk-cap-versions?per_page=50', $auth)->assertOk()->json('data.items');
expect(count($list))->toBeGreaterThanOrEqual(2);
});
test('§5 play_config publish is audited', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'audit play'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$itemPayload = [];
foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
$itemPayload[] = [
'play_code' => $t->play_code,
'is_enabled' => true,
'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000,
'display_order' => (int) $t->sort_order,
];
}
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();
expect(
AuditLog::query()
->where('module_code', 'play_config')
->where('action_code', 'publish')
->exists(),
)->toBeTrue();
});